workspace-mgr 0.2.2

Fixed-policy repository workspace manager for coding agents
use clap::Parser;

mod cli;
mod config;
mod discard;
mod doctor;
mod dvc;
mod error;
mod git;
mod hex;
mod instructions;
mod lock;
mod manifest;
mod output;
mod path;
mod policy;
mod process;
mod refresh;
mod runtime;
mod scaffold;
mod storage;
mod task_rename;
mod transaction;
mod update;

use crate::cli::{
    Cli, Command, ConfigCommand, PlanArgs, PublishCommandArgs, ScopedArgs, StorageCommand,
    TaskCommand,
};
use crate::config::Config;
use crate::discard::{TaskDiscardOptions, discard};
use crate::error::{Error, Result};
use crate::git::GitRepo;
use crate::lock::RepositoryLock;
use crate::manifest::{AdditionalScope, ResolvedTask, one_line, validate_additional_scopes};
use crate::output::{Format, print_human, print_json};
use crate::path::repo_path;
use crate::refresh::{RefreshOptions, execute as refresh};
use crate::runtime::{SetupOptions, setup};
use crate::scaffold::{InitOptions, TaskCreateOptions, create_task, init};
use crate::task_rename::{TaskRenameOptions, rename as rename_task};
use crate::transaction::{Operation, TransactionOptions, execute as transact, task_status};

fn main() {
    update::check_and_warn();
    let cli = Cli::parse();
    if let Err(error) = run(cli) {
        eprintln!("workspace-mgr: {error}");
        std::process::exit(2);
    }
}

fn run(cli: Cli) -> Result<()> {
    match cli.command {
        Command::Setup(args) => emit(
            &setup(&SetupOptions {
                runtime_dir: args.runtime_dir,
                dry_run: args.dry_run,
            })?,
            cli.format,
        ),
        Command::Init(args) => {
            let report = init(&InitOptions {
                repo: args.repo,
                s3_url: args.s3_url,
                s3_endpoint_url: args.s3_endpoint_url,
                dry_run: args.dry_run,
            })?;
            emit(&report, cli.format)
        }
        Command::Instructions(args) => {
            let repo = GitRepo::discover(&args.repo)?;
            let config = Config::load_compatible(&repo)?;
            let document = instructions::render(&repo, &config, args.topic.as_deref())?;
            if cli.format == Format::Json {
                print_json(&document)
            } else {
                print!("{}", document.markdown);
                Ok(())
            }
        }
        Command::Doctor(args) => {
            let report = doctor::inspect(&args.repo)?;
            if cli.format == Format::Json {
                print_json(&report)?;
            } else {
                println!("workspace-mgr doctor: {}", report.status);
                for check in &report.checks {
                    println!("{:<5} {:<28} {}", check.status, check.name, check.detail);
                }
            }
            if !report.healthy() {
                return Err(Error::message("doctor found one or more errors"));
            }
            Ok(())
        }
        Command::Config(args) => match args.command {
            ConfigCommand::Show(args) => {
                let repo = GitRepo::discover(&args.repo)?;
                let config = Config::load_compatible(&repo)?;
                if cli.format == Format::Json {
                    print_json(&config)
                } else {
                    print!("{}", config.render()?);
                    Ok(())
                }
            }
        },
        Command::Task(args) => match args.command {
            TaskCommand::Create(args) => emit(
                &create_task(&TaskCreateOptions {
                    repo: args.repo,
                    slug: args.slug,
                    title: args.title,
                    purpose: args.purpose,
                    kind: args.kind,
                    scopes: args.scopes,
                    scope_note: args.scope_note,
                    timestamp: args.timestamp,
                    dry_run: args.dry_run,
                })?,
                cli.format,
            ),
            TaskCommand::Rename(args) => emit(
                &rename_task(&TaskRenameOptions {
                    start: args.repo,
                    manifest: args.manifest,
                    new_slug: args.new_slug,
                    dry_run: args.dry_run,
                })?,
                cli.format,
            ),
            TaskCommand::Status(args) => emit(
                &task_status(&args.repo, args.manifest.as_deref())?,
                cli.format,
            ),
            TaskCommand::Discard(args) => emit(
                &discard(&TaskDiscardOptions {
                    start: args.repo,
                    manifest: args.manifest,
                    dry_run: args.dry_run,
                    confirm: args.confirm,
                })?,
                cli.format,
            ),
        },
        Command::Plan(args) => run_plan(args, cli.format),
        Command::Publish(args) => run_publish(args, cli.format),
        Command::Storage(args) => match args.command {
            StorageCommand::Status(args) => {
                let (repo, config, scopes, _lock) = scoped_context(&args.scoped, false)?;
                emit(
                    &storage::status(&repo, &config, &scopes, &args.paths)?,
                    cli.format,
                )
            }
            StorageCommand::Set(args) => {
                let (repo, config, scopes, _lock) = scoped_context(&args.scoped, true)?;
                emit(
                    &storage::set(
                        &repo,
                        &config,
                        &scopes,
                        &args.paths,
                        args.to,
                        &args.reason,
                        args.dry_run,
                    )?,
                    cli.format,
                )
            }
            StorageCommand::Reset(args) => {
                let (repo, config, scopes, _lock) = scoped_context(&args.scoped, true)?;
                emit(
                    &storage::reset(&repo, &config, &scopes, &args.paths, args.dry_run)?,
                    cli.format,
                )
            }
            StorageCommand::Hydrate(args) => {
                let (repo, config, scopes, _lock) = scoped_context(&args.scoped, true)?;
                emit(
                    &storage::hydrate(&repo, &config, &scopes, &args.paths, args.dry_run)?,
                    cli.format,
                )
            }
        },
        Command::Move(args) => {
            let (repo, config, scopes, _lock) = scoped_context(&args.scoped, true)?;
            emit(
                &storage::move_path(
                    &repo,
                    &config,
                    &scopes,
                    &args.old_path,
                    &args.new_path,
                    args.dry_run,
                )?,
                cli.format,
            )
        }
        Command::Refresh(args) => emit(
            &refresh(&RefreshOptions {
                repo: args.repo,
                dry_run: args.dry_run,
            })?,
            cli.format,
        ),
    }
}

fn run_plan(args: PlanArgs, format: Format) -> Result<()> {
    emit(
        &transact(&TransactionOptions {
            start: args.repo,
            manifest: args.manifest,
            message: None,
            include: args.include,
            scope_note: args.scope_note,
            allow_non_shared_head: args.allow_non_shared_head,
            dry_run: false,
            operation: Operation::Plan,
        })?,
        format,
    )
}

fn run_publish(args: PublishCommandArgs, format: Format) -> Result<()> {
    emit(
        &transact(&TransactionOptions {
            start: args.repo,
            manifest: args.manifest,
            message: Some(args.message),
            include: args.include,
            scope_note: args.scope_note,
            allow_non_shared_head: args.allow_non_shared_head,
            dry_run: args.dry_run,
            operation: Operation::Publish,
        })?,
        format,
    )
}

fn hydrate_scopes(
    task: &ResolvedTask,
    includes: &[String],
    scope_note: Option<&str>,
) -> Result<(Vec<String>, Vec<AdditionalScope>)> {
    let mut additional = task.additional_scopes.clone();
    if !includes.is_empty() && scope_note.is_none() {
        return Err(Error::message(
            "--include requires --scope-note describing its authorization",
        ));
    }
    if let Some(reason) = scope_note {
        let reason = one_line(reason, "scope note")?;
        for path in includes {
            additional.push(AdditionalScope {
                path: repo_path(path, "included scope")?,
                reason: reason.clone(),
            });
        }
    }
    let additional = validate_additional_scopes(task.task_path.as_deref(), additional)?;
    let scopes = task
        .task_path
        .iter()
        .cloned()
        .chain(additional.iter().map(|scope| scope.path.clone()))
        .collect();
    Ok((scopes, additional))
}

fn scoped_context(
    args: &ScopedArgs,
    exclusive: bool,
) -> Result<(GitRepo, Config, Vec<String>, Option<RepositoryLock>)> {
    let repo = match &args.manifest {
        Some(path) => GitRepo::discover_for_manifest(path)?,
        None => GitRepo::discover(&args.repo)?,
    };
    let lock = if exclusive {
        Some(RepositoryLock::acquire(&repo)?)
    } else {
        None
    };
    let config = Config::load_compatible(&repo)?;
    let manifest_path = match &args.manifest {
        Some(path) => path.clone(),
        None => ResolvedTask::discover(&repo, &args.repo)?,
    };
    let task = ResolvedTask::load(&repo, &config, &manifest_path)?;
    let (scopes, _) = hydrate_scopes(&task, &args.include, args.scope_note.as_deref())?;
    Ok((repo, config, scopes, lock))
}

fn emit<T: serde::Serialize>(value: &T, format: Format) -> Result<()> {
    match format {
        Format::Human => print_human(value),
        Format::Json => print_json(value),
    }
}