heddle-cli 0.15.5

An AI-native version control system
// SPDX-License-Identifier: Apache-2.0
//! Marker commands.

use anyhow::{Result, anyhow};
use objects::{object::MarkerName, store::ObjectStore};
use repo::Repository;
use verbs::{
    MarkerDeleteSelector, MarkerDeleteSelectorError, marker_bulk_delete_message,
    marker_create_message, marker_delete_message, marker_list_filter_matches,
    marker_prefix_is_valid, plan_marker_delete_selector,
};
// The wire payloads live in cli-contract so the schema registry registers
// the real serialization types.
pub(crate) use heddle_cli_contract::cli::commands::wire::history::{
    MarkerBulkDeleteOutput, MarkerEntry, MarkerListOutput, MarkerOpOutput,
};

use super::{
    advice::RecoveryAdvice,
    next_action::{NextActionValidationContext, write_full_command_json},
    snapshot::ensure_current_state,
};
use crate::{
    cli::{Cli, ThreadMarkerCommands, should_output_json},
    config::UserConfig,
};

pub fn cmd_thread_marker(
    cli: &Cli,
    repo: &Repository,
    command: ThreadMarkerCommands,
) -> Result<()> {
    match command {
        ThreadMarkerCommands::List { filter } => cmd_marker_list(cli, repo, filter),
        ThreadMarkerCommands::Create { name, .. } => cmd_marker_create(cli, repo, name),
        ThreadMarkerCommands::Delete { name, prefix } => {
            match plan_marker_delete_selector(name, prefix) {
                Ok(MarkerDeleteSelector::Name(name)) => cmd_marker_delete(cli, repo, name),
                Ok(MarkerDeleteSelector::Prefix(prefix)) => {
                    cmd_marker_delete_prefix(cli, repo, prefix)
                }
                // Clap enforces required_unless_present + conflicts_with; pure
                // planner still guards if constraints are relaxed.
                Err(MarkerDeleteSelectorError::Conflict) => {
                    Err(anyhow!(marker_delete_selector_conflict_advice()))
                }
                Err(MarkerDeleteSelectorError::Required) => {
                    Err(anyhow!(marker_delete_selector_required_advice()))
                }
            }
        }
        ThreadMarkerCommands::Show { name } => cmd_marker_show(cli, repo, name),
    }
}

fn cmd_marker_list(cli: &Cli, repo: &Repository, filter: Option<String>) -> Result<()> {
    let markers = repo.refs().list_markers()?;

    let entries: Vec<MarkerEntry> = markers
        .iter()
        // Prefix match (not a glob). Empty filter → no filter (shell accident).
        .filter(|name| marker_list_filter_matches(name.as_str(), filter.as_deref()))
        .filter_map(|name| {
            let state = repo.refs().get_marker(name).ok()??;
            Some(MarkerEntry {
                name: name.to_string(),
                state_id: state.short(),
            })
        })
        .collect();

    let output = MarkerListOutput {
        output_kind: "thread_marker_list",
        markers: entries,
    };

    if should_output_json(cli, Some(repo.config())) {
        write_full_command_json(
            &output,
            NextActionValidationContext::without_repo(&["thread", "marker"]),
        )?;
    } else {
        for entry in &output.markers {
            println!("{} -> {}", entry.name, entry.state_id);
        }
        if output.markers.is_empty() {
            println!("No markers");
        }
    }

    Ok(())
}

fn cmd_marker_create(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
    let current = ensure_current_state(
        repo,
        &UserConfig::load_default()?,
        Some(format!(
            "Bootstrap git-overlay before creating marker {}",
            name
        )),
    )?;

    let mn = MarkerName::new(&name);
    repo.create_marker_recorded(&mn, &current)?;

    let output = MarkerOpOutput {
        output_kind: "thread_marker_create",
        name: name.clone(),
        state_id: Some(current.short()),
        message: marker_create_message(&name, &current.short()),
    };

    if should_output_json(cli, Some(repo.config())) {
        write_full_command_json(
            &output,
            NextActionValidationContext::without_repo(&["thread", "marker"]),
        )?;
    } else {
        println!("{}", output.message);
    }

    Ok(())
}

fn cmd_marker_delete(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
    let mn = MarkerName::new(&name);
    repo.delete_marker_recorded(&mn)?
        .ok_or_else(|| anyhow!("Marker not found: {}", name))?;

    let output = MarkerOpOutput {
        output_kind: "thread_marker_delete",
        name: name.clone(),
        state_id: None,
        message: marker_delete_message(&name),
    };

    if should_output_json(cli, Some(repo.config())) {
        write_full_command_json(
            &output,
            NextActionValidationContext::without_repo(&["thread", "marker"]),
        )?;
    } else {
        println!("{}", output.message);
    }

    Ok(())
}

fn cmd_marker_delete_prefix(cli: &Cli, repo: &Repository, prefix: String) -> Result<()> {
    if !marker_prefix_is_valid(&prefix) {
        return Err(anyhow!(marker_delete_empty_prefix_advice()));
    }

    let all = repo.refs().list_markers()?;
    let matches: Vec<MarkerName> = all
        .into_iter()
        .filter(|name| name.starts_with(&prefix))
        .collect();

    let mut deleted: Vec<MarkerEntry> = Vec::with_capacity(matches.len());
    for name in &matches {
        // Skip if it disappeared between list and delete (concurrent delete).
        if let Some(state) = repo.delete_marker_recorded(name)? {
            deleted.push(MarkerEntry {
                name: name.to_string(),
                state_id: state.short(),
            });
        }
    }

    let count = deleted.len();
    let message = marker_bulk_delete_message(&prefix, count);

    let output = MarkerBulkDeleteOutput {
        output_kind: "thread_marker_delete",
        deleted,
        count,
        message,
    };

    if should_output_json(cli, Some(repo.config())) {
        write_full_command_json(
            &output,
            NextActionValidationContext::without_repo(&["thread", "marker"]),
        )?;
    } else {
        println!("{}", output.message);
        for entry in &output.deleted {
            println!("  {} -> {}", entry.name, entry.state_id);
        }
    }

    Ok(())
}

fn marker_delete_empty_prefix_advice() -> RecoveryAdvice {
    RecoveryAdvice::safety_refusal(
        "marker_delete_empty_prefix",
        "Refusing to delete markers: --prefix must be non-empty",
        "Inspect markers with `heddle thread marker list`, then rerun with a non-empty `--prefix`.",
        "an empty marker prefix matches every marker",
        "`heddle thread marker delete --prefix \"\"` would delete every marker ref",
        "no marker refs were deleted",
        "heddle thread marker list",
        vec![
            "heddle thread marker list".to_string(),
            "heddle thread marker delete --prefix <prefix>".to_string(),
        ],
    )
}

fn marker_delete_selector_conflict_advice() -> RecoveryAdvice {
    RecoveryAdvice::safety_refusal(
        "marker_delete_selector_conflict",
        "marker delete cannot combine <NAME> with --prefix",
        "Choose exactly one selector: delete one marker with `heddle thread marker delete <NAME>`, or delete a named group with `heddle thread marker delete --prefix <prefix>`.",
        "both an exact marker name and a prefix selector were supplied",
        "deleting with two selector modes would make the target marker set ambiguous",
        "no marker refs, repository objects, metadata, or worktree files were changed",
        "heddle thread marker list",
        vec![
            "heddle thread marker list".to_string(),
            "heddle thread marker delete <NAME>".to_string(),
            "heddle thread marker delete --prefix <prefix>".to_string(),
        ],
    )
}

fn marker_delete_selector_required_advice() -> RecoveryAdvice {
    RecoveryAdvice::safety_refusal(
        "marker_delete_selector_required",
        "marker delete requires <NAME> or --prefix <prefix>",
        "Inspect markers with `heddle thread marker list`, then delete one marker with `heddle thread marker delete <NAME>` or a named group with `heddle thread marker delete --prefix <prefix>`.",
        "no marker name or prefix selector was supplied",
        "deleting without a selector would have to guess which marker refs should be removed",
        "no marker refs, repository objects, metadata, or worktree files were changed",
        "heddle thread marker list",
        vec![
            "heddle thread marker list".to_string(),
            "heddle thread marker delete <NAME>".to_string(),
            "heddle thread marker delete --prefix <prefix>".to_string(),
        ],
    )
}

fn cmd_marker_show(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
    let state_id = repo
        .refs()
        .get_marker(&MarkerName::new(&name))?
        .ok_or_else(|| anyhow!("Marker not found: {}", name))?;

    let state = repo
        .store()
        .get_state(&state_id)?
        .ok_or_else(|| anyhow!("State not found for marker: {}", name))?;

    let output = MarkerOpOutput {
        output_kind: "thread_marker_show",
        name: name.clone(),
        state_id: Some(state_id.short()),
        message: format!("Marker '{}' -> {}", name, state_id.short()),
    };

    if should_output_json(cli, Some(repo.config())) {
        write_full_command_json(
            &output,
            NextActionValidationContext::without_repo(&["thread", "marker"]),
        )?;
    } else {
        println!("Marker: {}", name);
        println!("State: {}", state_id.short());
        if let Some(intent) = &state.intent {
            println!("Intent: {}", intent);
        }
        println!("Created: {}", state.created_at.format("%Y-%m-%d %H:%M:%S"));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn marker_delete_selector_advices_are_typed() {
        let conflict = marker_delete_selector_conflict_advice();
        assert_eq!(conflict.kind, "marker_delete_selector_conflict");
        assert_eq!(conflict.primary_command, "heddle thread marker list");
        assert!(conflict.primary_hint().contains("exactly one selector"));
        assert!(conflict.unsafe_condition.contains("exact marker name"));
        assert!(conflict.would_change.contains("ambiguous"));
        assert!(conflict.preserved.contains("no marker refs"));

        let required = marker_delete_selector_required_advice();
        assert_eq!(required.kind, "marker_delete_selector_required");
        assert_eq!(required.primary_command, "heddle thread marker list");
        assert!(
            required
                .primary_hint()
                .contains("heddle thread marker list")
        );
        assert!(required.unsafe_condition.contains("no marker name"));
        assert!(required.would_change.contains("guess"));
        assert!(required.preserved.contains("no marker refs"));
    }
}