heddle-cli 0.10.4

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

use anyhow::{Result, anyhow};
use heddle_core::{
    MarkerDeleteSelector, MarkerDeleteSelectorError, marker_bulk_delete_message,
    marker_create_message, marker_delete_message, marker_list_filter_matches,
    marker_prefix_is_valid, plan_marker_delete_selector,
};
use objects::{object::MarkerName, store::ObjectStore};
use oplog::OpLogRecorder;
use repo::Repository;
use serde::Serialize;

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

#[derive(Serialize)]
struct MarkerListOutput {
    output_kind: &'static str,
    markers: Vec<MarkerEntry>,
}

#[derive(Serialize)]
struct MarkerEntry {
    name: String,
    /// Short change-id of the state the marker points at.
    state_id: String,
}

#[derive(Serialize)]
struct MarkerOpOutput {
    output_kind: &'static str,
    name: String,
    /// Short change-id of the state the marker pointed at after the op.
    /// `None` for ops that delete the marker.
    state_id: Option<String>,
    message: String,
}

#[derive(Serialize)]
struct MarkerBulkDeleteOutput {
    output_kind: &'static str,
    deleted: Vec<MarkerEntry>,
    count: usize,
    message: String,
}

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())) {
        println!("{}", serde_json::to_string(&output)?);
    } 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.refs().create_marker(&mn, &current)?;
    repo.oplog().record_marker_create(&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())) {
        println!("{}", serde_json::to_string(&output)?);
    } else {
        println!("{}", output.message);
    }

    Ok(())
}

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

    repo.oplog().record_marker_delete(&mn, &state)?;

    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())) {
        println!("{}", serde_json::to_string(&output)?);
    } 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.refs().delete_marker(name)? {
            repo.oplog().record_marker_delete(name, &state)?;
            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())) {
        println!("{}", serde_json::to_string(&output)?);
    } 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())) {
        println!("{}", serde_json::to_string(&output)?);
    } 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"));
    }
}