gn-cli 0.1.9

git-notes CLI — add, list, show, sync, resolve notes
Documentation
use anyhow::{anyhow, Context, Result};
use clap::Args;
use gn_core::{Note, NotesEngine};
use std::process::Command;

#[derive(Args)]
pub struct ReplyArgs {
    /// Note ID, index number (1, 2, ...), or "latest" / "^" (interactive picker if omitted)
    pub id: Option<String>,

    /// Reply message
    #[arg(short, long)]
    pub message: String,

    /// Cryptographically sign the reply note with GPG or SSH key
    #[arg(short, long)]
    pub sign: bool,
}

pub fn run(args: &ReplyArgs) -> Result<()> {
    let engine = NotesEngine::new(".");
    let namespaces = vec!["comments", "review", "todos"];

    let mut all_notes = Vec::new();

    for ns in &namespaces {
        let namespace_enum = gn_core::Namespace::Custom(ns.to_string());
        if let Ok(notes) = engine.read_notes(&namespace_enum) {
            all_notes.extend(notes);
        }
    }

    if all_notes.is_empty() {
        return Err(anyhow!("No notes exist in the repository to reply to."));
    }

    // Interactive picker if ID omitted
    let found_note = match &args.id {
        None => match super::picker::pick_note("Select note to reply to:", &all_notes)? {
            Some(n) => Some(n.clone()),
            None => {
                println!("Cancelled.");
                return Ok(());
            }
        },
        Some(raw_id) => {
            let target = raw_id.trim().trim_start_matches('#');
            if target.eq_ignore_ascii_case("latest") || target == "^" {
                all_notes.last().cloned()
            } else if let Ok(idx) = target.parse::<usize>() {
                if idx >= 1 && idx <= all_notes.len() {
                    Some(all_notes[idx - 1].clone())
                } else {
                    None
                }
            } else {
                all_notes
                    .iter()
                    .find(|n| n.id.to_string().starts_with(target))
                    .cloned()
            }
        }
    };

    let parent_note =
        found_note.ok_or_else(|| anyhow!("Target note not found"))?;

    let name_output = Command::new("git")
        .args(["config", "user.name"])
        .output()
        .context("Failed to read user.name")?;
    let email_output = Command::new("git")
        .args(["config", "user.email"])
        .output()
        .context("Failed to read user.email")?;

    let author = format!(
        "{} <{}>",
        String::from_utf8_lossy(&name_output.stdout).trim(),
        String::from_utf8_lossy(&email_output.stdout).trim()
    );

    let mut reply = Note::reply(&parent_note, args.message.clone(), author);

    if super::signing::is_signing_requested(args.sign) {
        let payload = reply.signing_payload();
        let sig = super::signing::sign_payload(&payload)
            .context("Failed to cryptographically sign reply note")?;
        reply.signature = Some(sig);
    }

    let id = engine.write_note(&reply)?;
    if reply.signature.is_some() {
        println!(
            "\x1b[32m✔\x1b[0m Reply (signed) added to thread \x1b[36m{}\x1b[0m (Note ID: \x1b[36m{}\x1b[0m)",
            &parent_note.id.to_string()[..8],
            &id[..8.min(id.len())]
        );
    } else {
        println!(
            "\x1b[32m✔\x1b[0m Reply added to thread \x1b[36m{}\x1b[0m (Note ID: \x1b[36m{}\x1b[0m)",
            &parent_note.id.to_string()[..8],
            &id[..8.min(id.len())]
        );
    }

    Ok(())
}