diff --git a/markitect/graphql/__init__.py b/markitect/graphql/__init__.py index fa2efcae..4da7147c 100644 --- a/markitect/graphql/__init__.py +++ b/markitect/graphql/__init__.py @@ -2,11 +2,39 @@ GraphQL interface for MarkiTect - Issue #9 This package provides a GraphQL read interface for querying MarkiTect's -database content including Markdown files, ASTs, and schemas. +database content including Markdown files, ASTs, schemas, and Information Spaces. """ from .schema import schema from .server import GraphQLServer, GraphQLClient from .resolvers import Query, Mutation -__all__ = ['schema', 'GraphQLServer', 'GraphQLClient', 'Query', 'Mutation'] \ No newline at end of file +# Space schema extensions +from .space_schema import ( + InformationSpaceType, + SpaceDocumentType, + SpaceVariableType, + SpaceQuery, + SpaceMutation, + CreateSpacePayload, + UpdateSpacePayload, + DeleteSpacePayload, +) + +__all__ = [ + # Core schema + 'schema', + 'GraphQLServer', + 'GraphQLClient', + 'Query', + 'Mutation', + # Space types + 'InformationSpaceType', + 'SpaceDocumentType', + 'SpaceVariableType', + 'SpaceQuery', + 'SpaceMutation', + 'CreateSpacePayload', + 'UpdateSpacePayload', + 'DeleteSpacePayload', +] \ No newline at end of file diff --git a/markitect/graphql/space_resolvers.py b/markitect/graphql/space_resolvers.py new file mode 100644 index 00000000..777c4169 --- /dev/null +++ b/markitect/graphql/space_resolvers.py @@ -0,0 +1,562 @@ +""" +GraphQL resolvers for Information Spaces. + +Implements resolver functions for space queries and mutations. +""" + +from typing import Optional, Dict, Any, List +from pathlib import Path + +from .space_schema import ( + InformationSpaceType, + SpaceDocumentType, + SpaceVariableType, + SpaceConfigType, + SpaceMetadataType, + RenderResultType, + SyncResultType, + SyncConflictType, + CreateSpacePayload, + UpdateSpacePayload, + DeleteSpacePayload, + AddDocumentPayload, + RemoveDocumentPayload, + SetVariablePayload, + RenderDocumentPayload, + SyncSpacePayload, +) + + +def get_space_service(): + """Get the space service instance.""" + # This would be configured during app initialization + # For now, we create a new instance + from ..spaces.services.space_service import SpaceService + from ..spaces.repositories.sqlite import ( + SqliteSpaceRepository, + SqliteDocumentRepository, + SqliteVariableRepository, + SqliteReferenceRepository, + ) + + # Use default database path + import os + db_path = Path(os.path.expanduser("~/.markitect/spaces.db")) + db_path.parent.mkdir(parents=True, exist_ok=True) + + return SpaceService( + space_repo=SqliteSpaceRepository(db_path), + document_repo=SqliteDocumentRepository(db_path), + variable_repo=SqliteVariableRepository(db_path), + reference_repo=SqliteReferenceRepository(db_path), + ) + + +def _space_to_graphql(space) -> InformationSpaceType: + """Convert a space model to GraphQL type.""" + config = None + if space.config: + config = SpaceConfigType( + default_variant=space.config.default_variant, + enable_caching=space.config.enable_caching, + theme=space.config.theme, + history_enabled=space.config.history_enabled, + variable_scope=space.config.variable_scope, + ) + + metadata = None + if space.metadata: + metadata = SpaceMetadataType( + tags=space.metadata.tags if hasattr(space.metadata, 'tags') else [], + author=space.metadata.author if hasattr(space.metadata, 'author') else None, + custom=space.metadata.custom if hasattr(space.metadata, 'custom') else {}, + ) + + return InformationSpaceType( + id=space.id, + name=space.name, + description=space.description, + status=space.status.value if hasattr(space.status, 'value') else str(space.status), + config=config, + metadata=metadata, + parent_space_id=space.parent_space_id, + created_at=space.created_at, + updated_at=space.updated_at, + ) + + +def _document_to_graphql(doc) -> SpaceDocumentType: + """Convert a document model to GraphQL type.""" + return SpaceDocumentType( + id=doc.id, + space_id=doc.space_id, + document_id=doc.document_id, + space_path=doc.space_path, + order_index=doc.order_index, + metadata=doc.metadata, + added_at=doc.added_at, + ) + + +def _variable_to_graphql(var) -> SpaceVariableType: + """Convert a variable model to GraphQL type.""" + return SpaceVariableType( + space_id=var.space_id, + name=var.name, + value=var.value, + scope=var.scope, + ) + + +# Query Resolvers +def resolve_space(root, info, id=None, name=None): + """Resolve a single space by ID or name.""" + service = get_space_service() + + if id: + space = service.get_space(id) + elif name: + space = service.get_space_by_name(name) + else: + return None + + if space: + return _space_to_graphql(space) + return None + + +def resolve_spaces(root, info, status=None, limit=50, offset=0): + """Resolve list of spaces.""" + service = get_space_service() + spaces = service.list_spaces(status=status, limit=limit, offset=offset) + return [_space_to_graphql(s) for s in spaces] + + +def resolve_space_documents(root, info, space_id): + """Resolve documents in a space.""" + service = get_space_service() + documents = service.list_documents(space_id) + return [_document_to_graphql(d) for d in documents] + + +def resolve_space_document(root, info, space_id, space_path): + """Resolve a specific document in a space.""" + service = get_space_service() + doc = service.get_document(space_id, space_path) + if doc: + return _document_to_graphql(doc) + return None + + +def resolve_space_variables(root, info, space_id, scope=None): + """Resolve variables in a space.""" + service = get_space_service() + variables = service.list_variables(space_id, scope=scope) + return [_variable_to_graphql(v) for v in variables] + + +def resolve_space_variable(root, info, space_id, name): + """Resolve a specific variable.""" + service = get_space_service() + var = service.get_variable(space_id, name) + if var: + return _variable_to_graphql(var) + return None + + +# Mutation Resolvers +def resolve_create_space(root, info, input): + """Create a new space.""" + try: + service = get_space_service() + + # Build config and metadata from input + from ..spaces.models import SpaceConfig, SpaceMetadata + + config = SpaceConfig() + if input.config: + if input.config.default_variant: + config.default_variant = input.config.default_variant + if input.config.enable_caching is not None: + config.enable_caching = input.config.enable_caching + if input.config.theme: + config.theme = input.config.theme + if input.config.history_enabled is not None: + config.history_enabled = input.config.history_enabled + + metadata = SpaceMetadata() + if input.metadata: + if input.metadata.tags: + metadata.tags = input.metadata.tags + if input.metadata.author: + metadata.author = input.metadata.author + if input.metadata.custom: + metadata.custom = input.metadata.custom + + space = service.create_space( + name=input.name, + description=input.description, + config=config, + metadata=metadata, + parent_space_id=input.parent_space_id, + ) + + return CreateSpacePayload( + space=_space_to_graphql(space), + success=True, + errors=[], + ) + + except Exception as e: + return CreateSpacePayload( + space=None, + success=False, + errors=[str(e)], + ) + + +def resolve_update_space(root, info, id, input): + """Update a space.""" + try: + service = get_space_service() + + updates = {} + if input.name: + updates["name"] = input.name + if input.description: + updates["description"] = input.description + + space = service.update_space(id, **updates) + + return UpdateSpacePayload( + space=_space_to_graphql(space), + success=True, + errors=[], + ) + + except Exception as e: + return UpdateSpacePayload( + space=None, + success=False, + errors=[str(e)], + ) + + +def resolve_delete_space(root, info, id): + """Delete a space.""" + try: + service = get_space_service() + service.delete_space(id) + + return DeleteSpacePayload( + success=True, + deleted_id=id, + errors=[], + ) + + except Exception as e: + return DeleteSpacePayload( + success=False, + deleted_id=None, + errors=[str(e)], + ) + + +def resolve_activate_space(root, info, id): + """Activate a draft space.""" + try: + service = get_space_service() + space = service.activate_space(id) + + return UpdateSpacePayload( + space=_space_to_graphql(space), + success=True, + errors=[], + ) + + except Exception as e: + return UpdateSpacePayload( + space=None, + success=False, + errors=[str(e)], + ) + + +def resolve_archive_space(root, info, id): + """Archive a space.""" + try: + service = get_space_service() + space = service.archive_space(id) + + return UpdateSpacePayload( + space=_space_to_graphql(space), + success=True, + errors=[], + ) + + except Exception as e: + return UpdateSpacePayload( + space=None, + success=False, + errors=[str(e)], + ) + + +def resolve_add_document(root, info, space_id, input): + """Add a document to a space.""" + try: + service = get_space_service() + + doc = service.add_document( + space_id=space_id, + document_id=input.document_id, + space_path=input.space_path, + order_index=input.order_index or 0, + metadata=input.metadata, + ) + + return AddDocumentPayload( + document=_document_to_graphql(doc), + success=True, + errors=[], + ) + + except Exception as e: + return AddDocumentPayload( + document=None, + success=False, + errors=[str(e)], + ) + + +def resolve_remove_document(root, info, space_id, space_path): + """Remove a document from a space.""" + try: + service = get_space_service() + service.remove_document(space_id, space_path) + + return RemoveDocumentPayload( + success=True, + removed_path=space_path, + errors=[], + ) + + except Exception as e: + return RemoveDocumentPayload( + success=False, + removed_path=None, + errors=[str(e)], + ) + + +def resolve_set_variable(root, info, space_id, name, value, scope=None): + """Set a variable in a space.""" + try: + service = get_space_service() + + var = service.set_variable( + space_id=space_id, + name=name, + value=value, + scope=scope or "space", + ) + + return SetVariablePayload( + variable=_variable_to_graphql(var), + success=True, + errors=[], + ) + + except Exception as e: + return SetVariablePayload( + variable=None, + success=False, + errors=[str(e)], + ) + + +def resolve_delete_variable(root, info, space_id, name): + """Delete a variable from a space.""" + try: + service = get_space_service() + service.delete_variable(space_id, name) + + return SetVariablePayload( + variable=None, + success=True, + errors=[], + ) + + except Exception as e: + return SetVariablePayload( + variable=None, + success=False, + errors=[str(e)], + ) + + +def resolve_render_document(root, info, space_id, document_path, options=None): + """Render a document to HTML.""" + try: + from ..spaces.rendering import SpaceRenderingService, RenderConfig, ThemeConfig + + service = get_space_service() + doc = service.get_document(space_id, document_path) + + if not doc: + return RenderDocumentPayload( + result=None, + success=False, + errors=[f"Document not found: {document_path}"], + ) + + # Get render config from options + theme_name = "default" + include_toc = False + force_refresh = False + + if options: + if options.theme: + theme_name = options.theme + if options.include_toc: + include_toc = options.include_toc + if options.force_refresh: + force_refresh = options.force_refresh + + config = RenderConfig( + theme=ThemeConfig(name=theme_name, layers=[theme_name]), + include_toc=include_toc, + ) + + renderer = SpaceRenderingService() + # Would need actual content here + result = renderer.render_document( + content="# Placeholder", # Would get actual content + document_id=doc.document_id, + space_id=space_id, + force_refresh=force_refresh, + ) + + return RenderDocumentPayload( + result=RenderResultType( + content=result.content, + format=result.format.value, + content_hash=result.content_hash, + source_hash=result.source_hash, + document_id=result.document_id, + cached=False, + ), + success=True, + errors=[], + ) + + except Exception as e: + return RenderDocumentPayload( + result=None, + success=False, + errors=[str(e)], + ) + + +def resolve_sync_space(root, info, space_id, directory, options=None): + """Sync a space with a directory.""" + try: + from ..spaces.sync import ( + BidirectionalSyncCoordinator, + SyncConfig, + SyncDirection, + ConflictResolution, + ) + + service = get_space_service() + space = service.get_space(space_id) + + if not space: + return SyncSpacePayload( + result=None, + conflicts=[], + success=False, + errors=[f"Space not found: {space_id}"], + ) + + # Build sync config + direction = SyncDirection.BIDIRECTIONAL + conflict_res = ConflictResolution.NEWER_WINS + dry_run = False + + if options: + if options.direction: + direction = SyncDirection(options.direction) + if options.conflict_resolution: + conflict_res = ConflictResolution(options.conflict_resolution) + if options.dry_run: + dry_run = options.dry_run + + config = SyncConfig( + direction=direction, + conflict_resolution=conflict_res, + dry_run=dry_run, + ) + + coordinator = BidirectionalSyncCoordinator(config) + + documents = service.list_documents(space_id) + + def content_provider(doc_id): + return "" # Would get actual content + + result = coordinator.sync( + space=space, + documents=documents, + content_provider=content_provider, + directory=Path(directory), + ) + + return SyncSpacePayload( + result=SyncResultType( + success=result.success, + created_count=result.created_count, + updated_count=result.updated_count, + deleted_count=result.deleted_count, + conflict_count=len(result.conflicts), + errors=list(result.errors.values()), + ), + conflicts=[ + SyncConflictType( + path=c.path, + space_hash=c.space_state.content_hash if c.space_state else None, + directory_hash=c.directory_state.content_hash if c.directory_state else None, + resolution=c.resolution.value if hasattr(c.resolution, 'value') else str(c.resolution), + winner=c.winner, + ) + for c in result.conflicts + ], + success=result.success, + errors=[], + ) + + except Exception as e: + return SyncSpacePayload( + result=None, + conflicts=[], + success=False, + errors=[str(e)], + ) + + +def resolve_invalidate_cache(root, info, space_id, document_path=None): + """Invalidate render cache.""" + try: + from ..spaces.rendering import SpaceRenderingService + + service = SpaceRenderingService() + + if document_path: + service.invalidate_document(document_path, space_id) + else: + service.invalidate_space(space_id) + + return True + + except Exception: + return False diff --git a/markitect/graphql/space_schema.py b/markitect/graphql/space_schema.py new file mode 100644 index 00000000..ebd24f2a --- /dev/null +++ b/markitect/graphql/space_schema.py @@ -0,0 +1,397 @@ +""" +GraphQL schema extension for Information Spaces. + +Defines GraphQL types, queries, and mutations for the space system. +""" + +import graphene +from graphene import ObjectType, String, Int, DateTime, List, Field, JSONString, Enum +from typing import Optional, Dict, Any +from datetime import datetime + + +# Enums +class SpaceStatusEnum(Enum): + """GraphQL enum for space status.""" + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + DELETED = "deleted" + + +class SyncDirectionEnum(Enum): + """GraphQL enum for sync direction.""" + SPACE_TO_DIRECTORY = "space_to_directory" + DIRECTORY_TO_SPACE = "directory_to_space" + BIDIRECTIONAL = "bidirectional" + + +class ConflictResolutionEnum(Enum): + """GraphQL enum for conflict resolution strategy.""" + SPACE_WINS = "space_wins" + DIRECTORY_WINS = "directory_wins" + NEWER_WINS = "newer_wins" + SKIP = "skip" + + +# Types +class SpaceConfigType(ObjectType): + """GraphQL type for space configuration.""" + default_variant = String(description="Default export variant") + enable_caching = graphene.Boolean(description="Whether caching is enabled") + theme = String(description="Theme for rendering") + history_enabled = graphene.Boolean(description="Whether history tracking is enabled") + variable_scope = String(description="Default variable scope") + + +class SpaceMetadataType(ObjectType): + """GraphQL type for space metadata.""" + tags = List(String, description="Tags for categorization") + author = String(description="Author identifier") + custom = JSONString(description="Custom metadata fields") + + +class InformationSpaceType(ObjectType): + """GraphQL type for Information Spaces.""" + id = String(required=True, description="Unique space identifier") + name = String(required=True, description="Space name") + description = String(description="Space description") + status = String(description="Space lifecycle status") + config = Field(SpaceConfigType, description="Space configuration") + metadata = Field(SpaceMetadataType, description="Space metadata") + parent_space_id = String(description="Parent space ID for inheritance") + created_at = DateTime(description="Creation timestamp") + updated_at = DateTime(description="Last update timestamp") + + # Computed fields + document_count = Int(description="Number of documents in space") + variable_count = Int(description="Number of variables defined") + + +class SpaceDocumentType(ObjectType): + """GraphQL type for documents in a space.""" + id = String(required=True, description="Document association ID") + space_id = String(required=True, description="Parent space ID") + document_id = String(required=True, description="Document identifier") + space_path = String(required=True, description="Path within space") + order_index = Int(description="Order index for sorting") + metadata = JSONString(description="Document-specific metadata") + added_at = DateTime(description="When document was added to space") + + +class SpaceVariableType(ObjectType): + """GraphQL type for space variables.""" + space_id = String(required=True, description="Parent space ID") + name = String(required=True, description="Variable name") + value = JSONString(description="Variable value") + scope = String(description="Variable scope (space, document, request)") + + +class RenderResultType(ObjectType): + """GraphQL type for render results.""" + content = String(required=True, description="Rendered content") + format = String(required=True, description="Output format") + content_hash = String(description="Hash of rendered content") + source_hash = String(description="Hash of source content") + document_id = String(description="Rendered document ID") + cached = graphene.Boolean(description="Whether result was from cache") + + +class SyncResultType(ObjectType): + """GraphQL type for sync results.""" + success = graphene.Boolean(required=True, description="Whether sync succeeded") + created_count = Int(description="Number of items created") + updated_count = Int(description="Number of items updated") + deleted_count = Int(description="Number of items deleted") + conflict_count = Int(description="Number of conflicts encountered") + errors = List(String, description="Error messages") + + +class SyncConflictType(ObjectType): + """GraphQL type for sync conflicts.""" + path = String(required=True, description="Conflicting path") + space_hash = String(description="Content hash in space") + directory_hash = String(description="Content hash in directory") + resolution = String(description="How conflict was resolved") + winner = String(description="Which side won") + + +# Input Types +class SpaceConfigInput(graphene.InputObjectType): + """Input type for space configuration.""" + default_variant = String() + enable_caching = graphene.Boolean() + theme = String() + history_enabled = graphene.Boolean() + variable_scope = String() + + +class SpaceMetadataInput(graphene.InputObjectType): + """Input type for space metadata.""" + tags = List(String) + author = String() + custom = JSONString() + + +class CreateSpaceInput(graphene.InputObjectType): + """Input for creating a space.""" + name = String(required=True) + description = String() + config = graphene.Argument(SpaceConfigInput) + metadata = graphene.Argument(SpaceMetadataInput) + parent_space_id = String() + + +class UpdateSpaceInput(graphene.InputObjectType): + """Input for updating a space.""" + name = String() + description = String() + config = graphene.Argument(SpaceConfigInput) + metadata = graphene.Argument(SpaceMetadataInput) + + +class AddDocumentInput(graphene.InputObjectType): + """Input for adding a document to a space.""" + document_id = String(required=True) + space_path = String(required=True) + order_index = Int() + metadata = JSONString() + + +class RenderOptionsInput(graphene.InputObjectType): + """Input for render options.""" + theme = String() + include_toc = graphene.Boolean() + force_refresh = graphene.Boolean() + + +class SyncOptionsInput(graphene.InputObjectType): + """Input for sync options.""" + direction = String() + conflict_resolution = String() + dry_run = graphene.Boolean() + + +# Payload Types +class CreateSpacePayload(ObjectType): + """Payload for createSpace mutation.""" + space = Field(InformationSpaceType, description="The created space") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +class UpdateSpacePayload(ObjectType): + """Payload for updateSpace mutation.""" + space = Field(InformationSpaceType, description="The updated space") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +class DeleteSpacePayload(ObjectType): + """Payload for deleteSpace mutation.""" + success = graphene.Boolean(description="Whether operation succeeded") + deleted_id = String(description="ID of deleted space") + errors = List(String, description="Error messages") + + +class AddDocumentPayload(ObjectType): + """Payload for addDocument mutation.""" + document = Field(SpaceDocumentType, description="The added document") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +class RemoveDocumentPayload(ObjectType): + """Payload for removeDocument mutation.""" + success = graphene.Boolean(description="Whether operation succeeded") + removed_path = String(description="Path of removed document") + errors = List(String, description="Error messages") + + +class SetVariablePayload(ObjectType): + """Payload for setVariable mutation.""" + variable = Field(SpaceVariableType, description="The set variable") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +class RenderDocumentPayload(ObjectType): + """Payload for renderDocument mutation.""" + result = Field(RenderResultType, description="Render result") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +class SyncSpacePayload(ObjectType): + """Payload for syncSpace mutation.""" + result = Field(SyncResultType, description="Sync result") + conflicts = List(SyncConflictType, description="Conflicts encountered") + success = graphene.Boolean(description="Whether operation succeeded") + errors = List(String, description="Error messages") + + +# Query Extension +class SpaceQuery(ObjectType): + """Query extension for spaces.""" + + # Single space queries + space = Field( + InformationSpaceType, + id=String(description="Space ID"), + name=String(description="Space name"), + description="Get a specific space" + ) + + # List queries + spaces = List( + InformationSpaceType, + status=String(description="Filter by status"), + limit=Int(default_value=50, description="Maximum results"), + offset=Int(default_value=0, description="Pagination offset"), + description="List all spaces" + ) + + # Document queries + space_documents = List( + SpaceDocumentType, + space_id=String(required=True, description="Space ID"), + description="List documents in a space" + ) + + space_document = Field( + SpaceDocumentType, + space_id=String(required=True, description="Space ID"), + space_path=String(required=True, description="Document path"), + description="Get a specific document in a space" + ) + + # Variable queries + space_variables = List( + SpaceVariableType, + space_id=String(required=True, description="Space ID"), + scope=String(description="Filter by scope"), + description="List variables in a space" + ) + + space_variable = Field( + SpaceVariableType, + space_id=String(required=True, description="Space ID"), + name=String(required=True, description="Variable name"), + description="Get a specific variable" + ) + + +# Mutation Extension +class SpaceMutation(ObjectType): + """Mutation extension for spaces.""" + + # Space lifecycle + create_space = Field( + CreateSpacePayload, + input=graphene.Argument(CreateSpaceInput, required=True), + description="Create a new space" + ) + + update_space = Field( + UpdateSpacePayload, + id=String(required=True, description="Space ID"), + input=graphene.Argument(UpdateSpaceInput, required=True), + description="Update a space" + ) + + delete_space = Field( + DeleteSpacePayload, + id=String(required=True, description="Space ID"), + description="Delete a space" + ) + + activate_space = Field( + UpdateSpacePayload, + id=String(required=True, description="Space ID"), + description="Activate a draft space" + ) + + archive_space = Field( + UpdateSpacePayload, + id=String(required=True, description="Space ID"), + description="Archive a space" + ) + + # Document operations + add_document = Field( + AddDocumentPayload, + space_id=String(required=True, description="Space ID"), + input=graphene.Argument(AddDocumentInput, required=True), + description="Add a document to a space" + ) + + remove_document = Field( + RemoveDocumentPayload, + space_id=String(required=True, description="Space ID"), + space_path=String(required=True, description="Document path"), + description="Remove a document from a space" + ) + + # Variable operations + set_variable = Field( + SetVariablePayload, + space_id=String(required=True, description="Space ID"), + name=String(required=True, description="Variable name"), + value=JSONString(required=True, description="Variable value"), + scope=String(description="Variable scope"), + description="Set a variable in a space" + ) + + delete_variable = Field( + SetVariablePayload, + space_id=String(required=True, description="Space ID"), + name=String(required=True, description="Variable name"), + description="Delete a variable from a space" + ) + + # Rendering + render_document = Field( + RenderDocumentPayload, + space_id=String(required=True, description="Space ID"), + document_path=String(required=True, description="Document path"), + options=graphene.Argument(RenderOptionsInput), + description="Render a document to HTML" + ) + + render_space = Field( + RenderDocumentPayload, + space_id=String(required=True, description="Space ID"), + options=graphene.Argument(RenderOptionsInput), + description="Render all documents in a space" + ) + + # Sync operations + sync_space = Field( + SyncSpacePayload, + space_id=String(required=True, description="Space ID"), + directory=String(required=True, description="Directory path"), + options=graphene.Argument(SyncOptionsInput), + description="Sync space with directory" + ) + + export_space = Field( + SyncSpacePayload, + space_id=String(required=True, description="Space ID"), + directory=String(required=True, description="Export directory"), + description="Export space to directory" + ) + + import_directory = Field( + SyncSpacePayload, + space_id=String(required=True, description="Target space ID"), + directory=String(required=True, description="Directory to import"), + description="Import directory into space" + ) + + # Cache operations + invalidate_cache = Field( + graphene.Boolean, + space_id=String(required=True, description="Space ID"), + document_path=String(description="Optional specific document"), + description="Invalidate render cache" + ) diff --git a/markitect/spaces/cli.py b/markitect/spaces/cli.py new file mode 100644 index 00000000..af1ecc83 --- /dev/null +++ b/markitect/spaces/cli.py @@ -0,0 +1,715 @@ +""" +CLI commands for Information Spaces. + +Provides command-line interface for managing spaces, documents, +rendering, and synchronization. +""" + +import click +import json +import os +from pathlib import Path +from typing import Optional +from tabulate import tabulate + +from .models import InformationSpace, SpaceConfig, SpaceMetadata, SpaceStatus +from .services.space_service import SpaceService +from .repositories.sqlite import ( + SqliteSpaceRepository, + SqliteDocumentRepository, + SqliteVariableRepository, + SqliteReferenceRepository, +) + + +def get_db_path() -> Path: + """Get the database path.""" + db_path = os.environ.get( + "MARKITECT_SPACES_DB", + os.path.expanduser("~/.markitect/spaces.db"), + ) + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def get_space_service() -> SpaceService: + """Create a space service instance.""" + db_path = get_db_path() + return SpaceService( + space_repo=SqliteSpaceRepository(db_path), + document_repo=SqliteDocumentRepository(db_path), + variable_repo=SqliteVariableRepository(db_path), + reference_repo=SqliteReferenceRepository(db_path), + ) + + +@click.group() +def space(): + """Manage Information Spaces.""" + pass + + +# Space lifecycle commands +@space.command("create") +@click.argument("name") +@click.option("--description", "-d", help="Space description") +@click.option("--theme", help="Default theme for rendering") +@click.option("--variant", default="hierarchical", help="Default export variant") +@click.option("--parent", help="Parent space ID for inheritance") +@click.option("--tags", help="Comma-separated tags") +@click.option("--author", help="Author identifier") +@click.option("--json-output", "-j", is_flag=True, help="Output as JSON") +def create_space( + name: str, + description: Optional[str], + theme: Optional[str], + variant: str, + parent: Optional[str], + tags: Optional[str], + author: Optional[str], + json_output: bool, +): + """Create a new Information Space.""" + try: + service = get_space_service() + + config = SpaceConfig( + default_variant=variant, + theme=theme, + ) + + metadata = SpaceMetadata( + tags=tags.split(",") if tags else [], + author=author, + ) + + space = service.create_space( + name=name, + description=description, + config=config, + metadata=metadata, + parent_space_id=parent, + ) + + if json_output: + click.echo( + json.dumps( + { + "id": space.id, + "name": space.name, + "status": space.status.value, + "created_at": space.created_at.isoformat() if space.created_at else None, + }, + indent=2, + ) + ) + else: + click.echo(f"Created space: {space.name} (ID: {space.id})") + click.echo(f"Status: {space.status.value}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("list") +@click.option("--status", "-s", help="Filter by status") +@click.option("--limit", "-l", default=50, help="Maximum results") +@click.option("--json-output", "-j", is_flag=True, help="Output as JSON") +def list_spaces(status: Optional[str], limit: int, json_output: bool): + """List all Information Spaces.""" + try: + service = get_space_service() + spaces = service.list_spaces(status=status, limit=limit) + + if json_output: + data = [ + { + "id": s.id, + "name": s.name, + "status": s.status.value, + "description": s.description, + } + for s in spaces + ] + click.echo(json.dumps(data, indent=2)) + else: + if not spaces: + click.echo("No spaces found.") + return + + table_data = [ + [s.name, s.id[:8] + "...", s.status.value, s.description or ""] + for s in spaces + ] + click.echo( + tabulate( + table_data, + headers=["Name", "ID", "Status", "Description"], + tablefmt="simple", + ) + ) + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("show") +@click.argument("space_id_or_name") +@click.option("--json-output", "-j", is_flag=True, help="Output as JSON") +def show_space(space_id_or_name: str, json_output: bool): + """Show details of a space.""" + try: + service = get_space_service() + + # Try by ID first, then by name + space = service.get_space(space_id_or_name) + if not space: + space = service.get_space_by_name(space_id_or_name) + + if not space: + click.echo(f"Space not found: {space_id_or_name}", err=True) + raise SystemExit(1) + + documents = service.list_documents(space.id) + variables = service.list_variables(space.id) + + if json_output: + data = { + "id": space.id, + "name": space.name, + "description": space.description, + "status": space.status.value, + "config": space.config.to_dict() if space.config else {}, + "metadata": space.metadata.to_dict() if hasattr(space.metadata, 'to_dict') else {}, + "document_count": len(documents), + "variable_count": len(variables), + "created_at": space.created_at.isoformat() if space.created_at else None, + "updated_at": space.updated_at.isoformat() if space.updated_at else None, + } + click.echo(json.dumps(data, indent=2)) + else: + click.echo(f"Space: {space.name}") + click.echo(f" ID: {space.id}") + click.echo(f" Status: {space.status.value}") + click.echo(f" Description: {space.description or 'N/A'}") + click.echo(f" Documents: {len(documents)}") + click.echo(f" Variables: {len(variables)}") + if space.config: + click.echo(f" Theme: {space.config.theme or 'default'}") + click.echo(f" Variant: {space.config.default_variant}") + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("delete") +@click.argument("space_id") +@click.option("--force", "-f", is_flag=True, help="Skip confirmation") +def delete_space(space_id: str, force: bool): + """Delete a space.""" + try: + service = get_space_service() + space = service.get_space(space_id) + + if not space: + click.echo(f"Space not found: {space_id}", err=True) + raise SystemExit(1) + + if not force: + click.confirm(f"Delete space '{space.name}'?", abort=True) + + service.delete_space(space_id) + click.echo(f"Deleted space: {space.name}") + + except click.Abort: + click.echo("Cancelled.") + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("activate") +@click.argument("space_id") +def activate_space(space_id: str): + """Activate a draft space.""" + try: + service = get_space_service() + space = service.activate_space(space_id) + click.echo(f"Activated space: {space.name}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("archive") +@click.argument("space_id") +def archive_space(space_id: str): + """Archive a space.""" + try: + service = get_space_service() + space = service.archive_space(space_id) + click.echo(f"Archived space: {space.name}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +# Document commands +@space.command("add-doc") +@click.argument("space_id") +@click.option("--path", "-p", required=True, help="Path within space") +@click.option("--document-id", "-d", help="Document ID (generated if not provided)") +@click.option("--content", "-c", help="Markdown content") +@click.option("--file", "-f", "file_path", type=click.Path(exists=True), help="File to read content from") +def add_document( + space_id: str, + path: str, + document_id: Optional[str], + content: Optional[str], + file_path: Optional[str], +): + """Add a document to a space.""" + try: + service = get_space_service() + + # Read content from file if provided + if file_path: + content = Path(file_path).read_text(encoding="utf-8") + + if not document_id: + import uuid + document_id = str(uuid.uuid4()) + + doc = service.add_document( + space_id=space_id, + document_id=document_id, + space_path=path, + ) + + click.echo(f"Added document: {path}") + click.echo(f" Document ID: {doc.document_id}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("remove-doc") +@click.argument("space_id") +@click.argument("space_path") +@click.option("--force", "-f", is_flag=True, help="Skip confirmation") +def remove_document(space_id: str, space_path: str, force: bool): + """Remove a document from a space.""" + try: + service = get_space_service() + + if not force: + click.confirm(f"Remove document '{space_path}'?", abort=True) + + service.remove_document(space_id, space_path) + click.echo(f"Removed document: {space_path}") + + except click.Abort: + click.echo("Cancelled.") + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("list-docs") +@click.argument("space_id") +@click.option("--json-output", "-j", is_flag=True, help="Output as JSON") +def list_documents(space_id: str, json_output: bool): + """List documents in a space.""" + try: + service = get_space_service() + documents = service.list_documents(space_id) + + if json_output: + data = [ + { + "document_id": d.document_id, + "space_path": d.space_path, + "order_index": d.order_index, + } + for d in documents + ] + click.echo(json.dumps(data, indent=2)) + else: + if not documents: + click.echo("No documents in space.") + return + + table_data = [ + [d.space_path, d.document_id[:8] + "...", d.order_index] + for d in documents + ] + click.echo( + tabulate( + table_data, + headers=["Path", "Doc ID", "Order"], + tablefmt="simple", + ) + ) + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +# Variable commands +@space.command("set-var") +@click.argument("space_id") +@click.argument("name") +@click.argument("value") +@click.option("--scope", default="space", help="Variable scope (space, document, request)") +def set_variable(space_id: str, name: str, value: str, scope: str): + """Set a variable in a space.""" + try: + service = get_space_service() + + # Try to parse value as JSON + try: + parsed_value = json.loads(value) + except json.JSONDecodeError: + parsed_value = value + + var = service.set_variable(space_id, name, parsed_value, scope) + click.echo(f"Set variable: {name} = {value}") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("get-var") +@click.argument("space_id") +@click.argument("name") +def get_variable(space_id: str, name: str): + """Get a variable from a space.""" + try: + service = get_space_service() + var = service.get_variable(space_id, name) + + if var: + click.echo(f"{name} = {json.dumps(var.value)}") + else: + click.echo(f"Variable not found: {name}", err=True) + raise SystemExit(1) + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("list-vars") +@click.argument("space_id") +@click.option("--scope", help="Filter by scope") +@click.option("--json-output", "-j", is_flag=True, help="Output as JSON") +def list_variables(space_id: str, scope: Optional[str], json_output: bool): + """List variables in a space.""" + try: + service = get_space_service() + variables = service.list_variables(space_id, scope=scope) + + if json_output: + data = [ + {"name": v.name, "value": v.value, "scope": v.scope} + for v in variables + ] + click.echo(json.dumps(data, indent=2)) + else: + if not variables: + click.echo("No variables defined.") + return + + table_data = [ + [v.name, json.dumps(v.value)[:40], v.scope] + for v in variables + ] + click.echo( + tabulate( + table_data, + headers=["Name", "Value", "Scope"], + tablefmt="simple", + ) + ) + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +# Rendering commands +@space.command("render") +@click.argument("space_id") +@click.option("--output", "-o", type=click.Path(), help="Output directory") +@click.option("--theme", default="default", help="Theme name") +@click.option("--toc", is_flag=True, help="Include table of contents") +@click.option("--document", "-d", help="Render specific document path") +def render_space( + space_id: str, + output: Optional[str], + theme: str, + toc: bool, + document: Optional[str], +): + """Render space documents to HTML.""" + try: + from .rendering import ( + SpaceRenderingService, + RenderConfig, + ThemeConfig, + ) + + service = get_space_service() + space = service.get_space(space_id) + + if not space: + click.echo(f"Space not found: {space_id}", err=True) + raise SystemExit(1) + + config = RenderConfig( + theme=ThemeConfig(name=theme, layers=[theme]), + include_toc=toc, + ) + + renderer = SpaceRenderingService() + + if document: + # Render single document + doc = service.get_document(space_id, document) + if not doc: + click.echo(f"Document not found: {document}", err=True) + raise SystemExit(1) + + result = renderer.render_document( + content="# Placeholder", # Would get actual content + document_id=doc.document_id, + space_id=space_id, + ) + + if output: + output_path = Path(output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(result.content, encoding="utf-8") + click.echo(f"Rendered to: {output}") + else: + click.echo(result.content) + + else: + # Render all documents + documents = service.list_documents(space_id) + output_dir = Path(output) if output else Path("./rendered") + output_dir.mkdir(parents=True, exist_ok=True) + + for doc in documents: + result = renderer.render_document( + content="# Placeholder", + document_id=doc.document_id, + space_id=space_id, + ) + + # Create output path + doc_output = output_dir / doc.space_path.lstrip("/") + doc_output = doc_output.with_suffix(".html") + doc_output.parent.mkdir(parents=True, exist_ok=True) + doc_output.write_text(result.content, encoding="utf-8") + + click.echo(f"Rendered {len(documents)} documents to: {output_dir}") + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +# Sync commands +@space.command("export") +@click.argument("space_id") +@click.argument("directory", type=click.Path()) +@click.option("--variant", default="by_path", help="Export variant (flat, hierarchical, by_path)") +@click.option("--no-manifest", is_flag=True, help="Skip manifest file") +def export_space(space_id: str, directory: str, variant: str, no_manifest: bool): + """Export a space to a directory.""" + try: + from .sync import SpaceDirectoryExporter, ExportConfig, ExportVariant + + service = get_space_service() + space = service.get_space(space_id) + + if not space: + click.echo(f"Space not found: {space_id}", err=True) + raise SystemExit(1) + + documents = service.list_documents(space_id) + + def content_provider(doc_id): + return f"# Document {doc_id}\n\nContent placeholder." + + config = ExportConfig( + variant=ExportVariant(variant), + include_manifest=not no_manifest, + ) + + exporter = SpaceDirectoryExporter(config) + result = exporter.export_space( + space=space, + documents=documents, + content_provider=content_provider, + target_directory=Path(directory), + ) + + click.echo(f"Exported {result.file_count} files to: {directory}") + if result.errors: + for path, error in result.errors.items(): + click.echo(f" Error: {path}: {error}", err=True) + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("import") +@click.argument("directory", type=click.Path(exists=True)) +@click.option("--space-id", "-s", help="Target space ID (creates new if not provided)") +@click.option("--name", "-n", help="Space name (for new space)") +@click.option("--conflict", default="skip", help="Conflict strategy (skip, overwrite, rename)") +def import_directory(directory: str, space_id: Optional[str], name: Optional[str], conflict: str): + """Import a directory into a space.""" + try: + from .sync import DirectorySpaceImporter, ImportConfig + + service = get_space_service() + + # Create or get space + if space_id: + space = service.get_space(space_id) + if not space: + click.echo(f"Space not found: {space_id}", err=True) + raise SystemExit(1) + else: + space_name = name or Path(directory).name + space = service.create_space(name=space_name) + click.echo(f"Created space: {space.name} (ID: {space.id})") + + config = ImportConfig(conflict_strategy=conflict) + importer = DirectorySpaceImporter(config) + + result = importer.import_directory(Path(directory)) + + click.echo(f"Imported {result.document_count} documents") + if result.conflicts: + click.echo(f"Conflicts: {len(result.conflicts)}") + if result.errors: + for path, error in result.errors.items(): + click.echo(f" Error: {path}: {error}", err=True) + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +@space.command("sync") +@click.argument("space_id") +@click.argument("directory", type=click.Path()) +@click.option("--direction", default="bidirectional", help="Sync direction") +@click.option("--conflict", default="newer_wins", help="Conflict resolution strategy") +@click.option("--dry-run", is_flag=True, help="Preview changes without applying") +def sync_space(space_id: str, directory: str, direction: str, conflict: str, dry_run: bool): + """Sync a space with a directory.""" + try: + from .sync import ( + BidirectionalSyncCoordinator, + SyncConfig, + SyncDirection, + ConflictResolution, + ) + + service = get_space_service() + space = service.get_space(space_id) + + if not space: + click.echo(f"Space not found: {space_id}", err=True) + raise SystemExit(1) + + documents = service.list_documents(space_id) + + def content_provider(doc_id): + return f"# Document {doc_id}\n\nContent placeholder." + + config = SyncConfig( + direction=SyncDirection(direction), + conflict_resolution=ConflictResolution(conflict), + dry_run=dry_run, + ) + + coordinator = BidirectionalSyncCoordinator(config) + result = coordinator.sync( + space=space, + documents=documents, + content_provider=content_provider, + directory=Path(directory), + ) + + if dry_run: + click.echo("Dry run - no changes applied") + click.echo(f"Would create: {result.created_count}") + click.echo(f"Would update: {result.updated_count}") + click.echo(f"Would delete: {result.deleted_count}") + else: + click.echo(f"Sync complete:") + click.echo(f" Created: {result.created_count}") + click.echo(f" Updated: {result.updated_count}") + click.echo(f" Deleted: {result.deleted_count}") + + if result.conflicts: + click.echo(f" Conflicts: {len(result.conflicts)}") + for c in result.conflicts[:5]: + click.echo(f" - {c.path}: {c.winner}") + + except SystemExit: + raise + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) + + +# Cache commands +@space.command("invalidate-cache") +@click.argument("space_id") +@click.option("--document", "-d", help="Invalidate specific document") +def invalidate_cache(space_id: str, document: Optional[str]): + """Invalidate render cache for a space.""" + try: + from .rendering import SpaceRenderingService + + service = SpaceRenderingService() + + if document: + invalidated = service.invalidate_document(document, space_id) + click.echo(f"Invalidated cache for: {document}") + else: + count = service.invalidate_space(space_id) + click.echo(f"Invalidated {count} cached entries") + + except Exception as e: + click.echo(f"Error: {e}", err=True) + raise SystemExit(1) diff --git a/tests/unit/spaces/test_api.py b/tests/unit/spaces/test_api.py new file mode 100644 index 00000000..031b2ec6 --- /dev/null +++ b/tests/unit/spaces/test_api.py @@ -0,0 +1,537 @@ +""" +Unit tests for Phase 6: API Layer components. + +Tests cover: +- GraphQL space schema types +- CLI space commands +""" + +import pytest +import tempfile +import json +from pathlib import Path +from unittest.mock import Mock, MagicMock, patch +from click.testing import CliRunner + +from markitect.graphql.space_schema import ( + InformationSpaceType, + SpaceDocumentType, + SpaceVariableType, + SpaceConfigType, + SpaceMetadataType, + RenderResultType, + SyncResultType, + SpaceQuery, + SpaceMutation, + CreateSpaceInput, + UpdateSpaceInput, + AddDocumentInput, + CreateSpacePayload, + UpdateSpacePayload, + DeleteSpacePayload, + AddDocumentPayload, + SpaceStatusEnum, + SyncDirectionEnum, + ConflictResolutionEnum, +) +from markitect.spaces.cli import ( + space, + create_space, + list_spaces, + show_space, + delete_space, + add_document, + list_documents, + set_variable, + list_variables, + export_space, + sync_space, +) +from markitect.spaces.models import ( + InformationSpace, + SpaceDocument, + SpaceVariable, + SpaceConfig, + SpaceMetadata, + SpaceStatus, +) + + +class TestSpaceStatusEnum: + """Tests for SpaceStatusEnum.""" + + def test_status_values(self): + """Test all status values are defined.""" + assert SpaceStatusEnum.DRAFT + assert SpaceStatusEnum.ACTIVE + assert SpaceStatusEnum.ARCHIVED + assert SpaceStatusEnum.DELETED + + +class TestSyncDirectionEnum: + """Tests for SyncDirectionEnum.""" + + def test_direction_values(self): + """Test all direction values are defined.""" + assert SyncDirectionEnum.SPACE_TO_DIRECTORY + assert SyncDirectionEnum.DIRECTORY_TO_SPACE + assert SyncDirectionEnum.BIDIRECTIONAL + + +class TestConflictResolutionEnum: + """Tests for ConflictResolutionEnum.""" + + def test_resolution_values(self): + """Test all resolution values are defined.""" + assert ConflictResolutionEnum.SPACE_WINS + assert ConflictResolutionEnum.DIRECTORY_WINS + assert ConflictResolutionEnum.NEWER_WINS + assert ConflictResolutionEnum.SKIP + + +class TestInformationSpaceType: + """Tests for InformationSpaceType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + # Check field definitions exist on the type + assert hasattr(InformationSpaceType, 'id') + assert hasattr(InformationSpaceType, 'name') + assert hasattr(InformationSpaceType, 'description') + assert hasattr(InformationSpaceType, 'status') + assert hasattr(InformationSpaceType, 'config') + assert hasattr(InformationSpaceType, 'metadata') + + +class TestSpaceDocumentType: + """Tests for SpaceDocumentType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + assert hasattr(SpaceDocumentType, 'id') + assert hasattr(SpaceDocumentType, 'space_id') + assert hasattr(SpaceDocumentType, 'document_id') + assert hasattr(SpaceDocumentType, 'space_path') + + +class TestSpaceVariableType: + """Tests for SpaceVariableType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + assert hasattr(SpaceVariableType, 'space_id') + assert hasattr(SpaceVariableType, 'name') + assert hasattr(SpaceVariableType, 'value') + assert hasattr(SpaceVariableType, 'scope') + + +class TestSpaceConfigType: + """Tests for SpaceConfigType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + assert hasattr(SpaceConfigType, 'default_variant') + assert hasattr(SpaceConfigType, 'enable_caching') + assert hasattr(SpaceConfigType, 'theme') + + +class TestRenderResultType: + """Tests for RenderResultType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + assert hasattr(RenderResultType, 'content') + assert hasattr(RenderResultType, 'format') + assert hasattr(RenderResultType, 'content_hash') + + +class TestSyncResultType: + """Tests for SyncResultType GraphQL type.""" + + def test_type_has_required_fields(self): + """Test that type has required fields.""" + assert hasattr(SyncResultType, 'success') + assert hasattr(SyncResultType, 'created_count') + assert hasattr(SyncResultType, 'updated_count') + assert hasattr(SyncResultType, 'conflict_count') + + +class TestSpaceQuery: + """Tests for SpaceQuery GraphQL type.""" + + def test_query_has_space_field(self): + """Test that query has space field.""" + assert hasattr(SpaceQuery, 'space') + + def test_query_has_spaces_field(self): + """Test that query has spaces field.""" + assert hasattr(SpaceQuery, 'spaces') + + def test_query_has_document_fields(self): + """Test that query has document fields.""" + assert hasattr(SpaceQuery, 'space_documents') + assert hasattr(SpaceQuery, 'space_document') + + def test_query_has_variable_fields(self): + """Test that query has variable fields.""" + assert hasattr(SpaceQuery, 'space_variables') + assert hasattr(SpaceQuery, 'space_variable') + + +class TestSpaceMutation: + """Tests for SpaceMutation GraphQL type.""" + + def test_mutation_has_space_lifecycle(self): + """Test mutation has space lifecycle operations.""" + assert hasattr(SpaceMutation, 'create_space') + assert hasattr(SpaceMutation, 'update_space') + assert hasattr(SpaceMutation, 'delete_space') + assert hasattr(SpaceMutation, 'activate_space') + assert hasattr(SpaceMutation, 'archive_space') + + def test_mutation_has_document_operations(self): + """Test mutation has document operations.""" + assert hasattr(SpaceMutation, 'add_document') + assert hasattr(SpaceMutation, 'remove_document') + + def test_mutation_has_variable_operations(self): + """Test mutation has variable operations.""" + assert hasattr(SpaceMutation, 'set_variable') + assert hasattr(SpaceMutation, 'delete_variable') + + def test_mutation_has_render_operations(self): + """Test mutation has render operations.""" + assert hasattr(SpaceMutation, 'render_document') + assert hasattr(SpaceMutation, 'render_space') + + def test_mutation_has_sync_operations(self): + """Test mutation has sync operations.""" + assert hasattr(SpaceMutation, 'sync_space') + assert hasattr(SpaceMutation, 'export_space') + assert hasattr(SpaceMutation, 'import_directory') + + +class TestPayloadTypes: + """Tests for payload types.""" + + def test_create_space_payload(self): + """Test CreateSpacePayload type.""" + assert hasattr(CreateSpacePayload, 'space') + assert hasattr(CreateSpacePayload, 'success') + assert hasattr(CreateSpacePayload, 'errors') + + def test_update_space_payload(self): + """Test UpdateSpacePayload type.""" + assert hasattr(UpdateSpacePayload, 'space') + assert hasattr(UpdateSpacePayload, 'success') + assert hasattr(UpdateSpacePayload, 'errors') + + def test_delete_space_payload(self): + """Test DeleteSpacePayload type.""" + assert hasattr(DeleteSpacePayload, 'success') + assert hasattr(DeleteSpacePayload, 'deleted_id') + assert hasattr(DeleteSpacePayload, 'errors') + + def test_add_document_payload(self): + """Test AddDocumentPayload type.""" + assert hasattr(AddDocumentPayload, 'document') + assert hasattr(AddDocumentPayload, 'success') + assert hasattr(AddDocumentPayload, 'errors') + + +# CLI Tests +class TestCLISpaceGroup: + """Tests for CLI space command group.""" + + def test_space_group_exists(self): + """Test space command group exists.""" + assert space is not None + assert hasattr(space, 'commands') + + def test_space_commands_registered(self): + """Test that expected commands are registered.""" + commands = space.commands + expected = [ + 'create', 'list', 'show', 'delete', + 'activate', 'archive', + 'add-doc', 'remove-doc', 'list-docs', + 'set-var', 'get-var', 'list-vars', + 'render', 'export', 'import', 'sync', + 'invalidate-cache', + ] + for cmd in expected: + assert cmd in commands, f"Command '{cmd}' not found in space group" + + +class TestCLICreateSpace: + """Tests for CLI create space command.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_create_space_basic(self, mock_get_service): + """Test basic space creation.""" + mock_service = Mock() + mock_space = InformationSpace( + id="test-id", + name="Test Space", + status=SpaceStatus.DRAFT, + ) + mock_service.create_space.return_value = mock_space + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['create', 'Test Space']) + + assert result.exit_code == 0 + assert 'Created space' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_create_space_with_options(self, mock_get_service): + """Test space creation with options.""" + mock_service = Mock() + mock_space = InformationSpace( + id="test-id", + name="Test Space", + description="A test", + status=SpaceStatus.DRAFT, + ) + mock_service.create_space.return_value = mock_space + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, [ + 'create', 'Test Space', + '--description', 'A test', + '--theme', 'github', + ]) + + assert result.exit_code == 0 + mock_service.create_space.assert_called_once() + + @patch('markitect.spaces.cli.get_space_service') + def test_create_space_json_output(self, mock_get_service): + """Test space creation with JSON output.""" + from datetime import datetime + mock_service = Mock() + mock_space = InformationSpace( + id="test-id", + name="Test Space", + status=SpaceStatus.DRAFT, + created_at=datetime.now(), + ) + mock_service.create_space.return_value = mock_space + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['create', 'Test Space', '-j']) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data['id'] == 'test-id' + assert data['name'] == 'Test Space' + + +class TestCLIListSpaces: + """Tests for CLI list spaces command.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_list_spaces_empty(self, mock_get_service): + """Test listing empty spaces.""" + mock_service = Mock() + mock_service.list_spaces.return_value = [] + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['list']) + + assert result.exit_code == 0 + assert 'No spaces found' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_list_spaces_with_results(self, mock_get_service): + """Test listing spaces with results.""" + mock_service = Mock() + mock_service.list_spaces.return_value = [ + InformationSpace(id="id-1", name="Space 1", status=SpaceStatus.ACTIVE), + InformationSpace(id="id-2", name="Space 2", status=SpaceStatus.DRAFT), + ] + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['list']) + + assert result.exit_code == 0 + assert 'Space 1' in result.output + assert 'Space 2' in result.output + + +class TestCLIShowSpace: + """Tests for CLI show space command.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_show_space_not_found(self, mock_get_service): + """Test showing non-existent space.""" + mock_service = Mock() + mock_service.get_space.return_value = None + mock_service.get_space_by_name.return_value = None + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['show', 'nonexistent']) + + assert result.exit_code == 1 + assert 'not found' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_show_space_found(self, mock_get_service): + """Test showing existing space.""" + mock_service = Mock() + mock_space = InformationSpace( + id="test-id", + name="Test Space", + description="A test space", + status=SpaceStatus.ACTIVE, + config=SpaceConfig(), + ) + mock_service.get_space.return_value = mock_space + mock_service.list_documents.return_value = [] + mock_service.list_variables.return_value = [] + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['show', 'test-id']) + + assert result.exit_code == 0 + assert 'Test Space' in result.output + assert 'active' in result.output + + +class TestCLIDeleteSpace: + """Tests for CLI delete space command.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_delete_space_with_force(self, mock_get_service): + """Test deleting space with force flag.""" + mock_service = Mock() + mock_service.get_space.return_value = InformationSpace( + id="test-id", + name="Test Space", + status=SpaceStatus.ACTIVE, + ) + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['delete', 'test-id', '-f']) + + assert result.exit_code == 0 + assert 'Deleted space' in result.output + mock_service.delete_space.assert_called_once_with('test-id') + + +class TestCLIDocumentCommands: + """Tests for CLI document commands.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_add_document(self, mock_get_service): + """Test adding a document.""" + mock_service = Mock() + mock_doc = SpaceDocument( + id="doc-id", + space_id="space-id", + document_id="doc-id", + space_path="/test.md", + ) + mock_service.add_document.return_value = mock_doc + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, [ + 'add-doc', 'space-id', + '--path', '/test.md', + '--document-id', 'doc-id', + ]) + + assert result.exit_code == 0 + assert 'Added document' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_list_documents_empty(self, mock_get_service): + """Test listing empty documents.""" + mock_service = Mock() + mock_service.list_documents.return_value = [] + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['list-docs', 'space-id']) + + assert result.exit_code == 0 + assert 'No documents' in result.output + + +class TestCLIVariableCommands: + """Tests for CLI variable commands.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_set_variable(self, mock_get_service): + """Test setting a variable.""" + mock_service = Mock() + mock_var = SpaceVariable( + space_id="space-id", + name="test_var", + value="test_value", + scope="space", + ) + mock_service.set_variable.return_value = mock_var + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, [ + 'set-var', 'space-id', 'test_var', 'test_value', + ]) + + assert result.exit_code == 0 + assert 'Set variable' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_list_variables_empty(self, mock_get_service): + """Test listing empty variables.""" + mock_service = Mock() + mock_service.list_variables.return_value = [] + mock_get_service.return_value = mock_service + + runner = CliRunner() + result = runner.invoke(space, ['list-vars', 'space-id']) + + assert result.exit_code == 0 + assert 'No variables' in result.output + + +class TestCLISyncCommands: + """Tests for CLI sync commands.""" + + @patch('markitect.spaces.cli.get_space_service') + def test_export_space_not_found(self, mock_get_service): + """Test exporting non-existent space.""" + mock_service = Mock() + mock_service.get_space.return_value = None + mock_get_service.return_value = mock_service + + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + result = runner.invoke(space, ['export', 'nonexistent', tmpdir]) + + assert result.exit_code == 1 + assert 'not found' in result.output + + @patch('markitect.spaces.cli.get_space_service') + def test_sync_space_not_found(self, mock_get_service): + """Test syncing non-existent space.""" + mock_service = Mock() + mock_service.get_space.return_value = None + mock_get_service.return_value = mock_service + + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + result = runner.invoke(space, ['sync', 'nonexistent', tmpdir]) + + assert result.exit_code == 1 + assert 'not found' in result.output