"""Format command for basic-memory CLI.""" from pathlib import Path from typing import Annotated, Optional import typer from loguru import logger from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn from basic_memory.cli.app import app from basic_memory.cli.commands.command_utils import run_with_cleanup from basic_memory.config import ConfigManager, get_project_config from basic_memory.file_utils import format_file console = Console() def is_markdown_extension(path: Path) -> bool: """Check if file has a markdown extension.""" return path.suffix.lower() in (".md", ".markdown") async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]: """Format a single file. Returns: Tuple of (path, success, error_message) """ try: result = await format_file( file_path, app_config, is_markdown=is_markdown_extension(file_path) ) if result is not None: return (file_path, True, None) else: return (file_path, False, "No formatter configured or formatting skipped") except Exception as e: return (file_path, False, str(e)) async def format_files( paths: list[Path], app_config, show_progress: bool = True ) -> tuple[int, int, list[tuple[Path, str]]]: """Format multiple files. Returns: Tuple of (formatted_count, skipped_count, errors) """ formatted = 0 skipped = 0 errors: list[tuple[Path, str]] = [] if show_progress: with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console, ) as progress: task = progress.add_task("Formatting files...", total=len(paths)) for file_path in paths: path, success, error = await format_single_file(file_path, app_config) if success: formatted += 1 elif error and "No formatter configured" not in error: errors.append((path, error)) else: skipped += 1 progress.update(task, advance=1) else: for file_path in paths: path, success, error = await format_single_file(file_path, app_config) if success: formatted += 1 elif error and "No formatter configured" not in error: errors.append((path, error)) else: skipped += 1 return formatted, skipped, errors async def run_format( path: Optional[Path] = None, project: Optional[str] = None, ) -> None: """Run the format command.""" app_config = ConfigManager().config # Check if formatting is enabled if ( not app_config.format_on_save and not app_config.formatter_command and not app_config.formatters ): console.print( "[yellow]No formatters configured. Set format_on_save=true and " "formatter_command or formatters in your config.[/yellow]" ) console.print( "\nExample config (~/.basic-memory/config.json):\n" ' "format_on_save": true,\n' ' "formatter_command": "prettier --write {file}"\n' ) raise typer.Exit(1) # Temporarily enable format_on_save for this command # (so format_file actually runs the formatter) original_format_on_save = app_config.format_on_save app_config.format_on_save = True try: # Determine which files to format if path: # Format specific file or directory if path.is_file(): files = [path] elif path.is_dir(): # Find all markdown and json files files = ( list(path.rglob("*.md")) + list(path.rglob("*.json")) + list(path.rglob("*.canvas")) ) else: console.print(f"[red]Path not found: {path}[/red]") raise typer.Exit(1) else: # Format all files in project project_config = get_project_config(project) project_path = Path(project_config.home) if not project_path.exists(): console.print(f"[red]Project path not found: {project_path}[/red]") raise typer.Exit(1) # Find all markdown and json files files = ( list(project_path.rglob("*.md")) + list(project_path.rglob("*.json")) + list(project_path.rglob("*.canvas")) ) if not files: console.print("[yellow]No files found to format.[/yellow]") return console.print(f"Found {len(files)} file(s) to format...") formatted, skipped, errors = await format_files(files, app_config) # Print summary console.print() if formatted > 0: console.print(f"[green]Formatted: {formatted} file(s)[/green]") if skipped > 0: console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]") if errors: console.print(f"[red]Errors: {len(errors)} file(s)[/red]") for path, error in errors: console.print(f" [red]{path}[/red]: {error}") finally: # Restore original setting app_config.format_on_save = original_format_on_save @app.command() def format( path: Annotated[ Optional[Path], typer.Argument(help="File or directory to format. Defaults to current project."), ] = None, project: Annotated[ Optional[str], typer.Option("--project", "-p", help="Project name to format."), ] = None, ) -> None: """Format files using configured formatters. Uses the formatter_command or formatters settings from your config. By default, formats all .md, .json, and .canvas files in the current project. Examples: bm format # Format all files in current project bm format --project research # Format files in specific project bm format notes/meeting.md # Format a specific file bm format notes/ # Format all files in directory """ try: run_with_cleanup(run_format(path, project)) except Exception as e: if not isinstance(e, typer.Exit): logger.error(f"Error formatting files: {e}") console.print(f"[red]Error formatting files: {e}[/red]") raise typer.Exit(code=1) raise