agileplus-cli 0.3.0

Typed intent graphs for engineering work — convert prompts into validated DAGs of intents, features, tasks, tests, and edges, then store, query, and serve them from Rust, HTTP, or MCP.
//! `agileplus note` — attach, list, or delete free-form notes on a stored graph.

use std::path::PathBuf;

use agileplus_sqlite::Storage;
use anyhow::{Context, Result};
use clap::{Args, Subcommand};

/// Manage free-form notes on a stored intent graph.
#[derive(Debug, Args)]
#[command(name = "note")]
pub struct NoteCommand {
    /// Path to the SQLite database.
    #[arg(short, long)]
    db: PathBuf,

    /// Graph id to operate on.
    #[arg(short, long)]
    id: String,

    #[command(subcommand)]
    action: NoteAction,
}

#[derive(Debug, Subcommand)]
enum NoteAction {
    /// Append a note to a graph.
    Add {
        /// Note text. Use `-` to read from stdin.
        #[arg(short, long)]
        body: Option<String>,

        /// Read note text from this file (`-` means stdin).
        #[arg(short = 'f', long = "file")]
        file: Option<PathBuf>,
    },
    /// List notes on a graph.
    List,
    /// Delete a note by id.
    Delete {
        /// Note id (from `note list`).
        note_id: i64,
    },
}

impl NoteCommand {
    pub fn run(&self) -> Result<()> {
        let storage = Storage::open(&self.db)
            .with_context(|| format!("Failed to open storage at: {}", self.db.display()))?;

        match &self.action {
            NoteAction::Add { body, file } => {
                let text = read_body(body.as_deref(), file.as_ref())?;
                let id = storage
                    .add_note(&self.id, &text)
                    .with_context(|| format!("Failed to add note to graph {}", self.id))?;
                println!("noted: {} -> note {id}", self.id);
            }
            NoteAction::List => {
                let notes = storage
                    .list_notes(&self.id)
                    .with_context(|| format!("Failed to list notes for graph {}", self.id))?;
                if notes.is_empty() {
                    println!("(no notes on {})", self.id);
                    return Ok(());
                }
                println!("{} note(s) on {}:", notes.len(), self.id);
                for n in &notes {
                    let preview: String = n.body.chars().take(80).collect();
                    println!(
                        "  #{} [{}] {}",
                        n.id, n.created_at, preview
                    );
                }
            }
            NoteAction::Delete { note_id } => {
                let removed = storage
                    .delete_note(&self.id, *note_id)
                    .with_context(|| {
                        format!("Failed to delete note {note_id} on graph {}", self.id)
                    })?;
                if removed {
                    println!("deleted note {note_id} on {}", self.id);
                } else {
                    println!("no such note: {note_id} on {}", self.id);
                }
            }
        }
        Ok(())
    }
}

/// Resolve the note body from `--body` or `--file` (`-` = stdin).
/// Exits with an error if neither is provided, or if both are provided.
fn read_body(body: Option<&str>, file: Option<&PathBuf>) -> Result<String> {
    match (body, file) {
        (Some(b), None) => Ok(b.to_string()),
        (None, Some(path)) => {
            if path == &PathBuf::from("-") {
                let mut buf = String::new();
                std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
                    .context("reading note body from stdin")?;
                Ok(buf)
            } else {
                std::fs::read_to_string(path)
                    .with_context(|| format!("reading note body from {}", path.display()))
            }
        }
        (Some(_), Some(_)) => {
            anyhow::bail!("provide either --body or --file, not both")
        }
        (None, None) => {
            anyhow::bail!("provide --body <text> or --file <path> (use - for stdin)")
        }
    }
}