cellstate/entity/
note.rs

1//! `cellstate note` — Manage notes (free-form text memory entries).
2//!
3//! Subcommands: create, list, get, update, delete, search.
4
5use anyhow::{anyhow, Result};
6use clap::{Arg, ArgMatches, Command};
7
8use cellstate_core::api_types::{
9    CreateNoteRequest, ListNotesResponse, NoteResponse, SearchRequest, SearchResponse,
10    UpdateNoteRequest,
11};
12use cellstate_core::{EntityType, NoteType, TTL};
13
14use super::client::ApiClient;
15use super::output::OutputConfig;
16
17const BASE: &str = "/api/v1/notes";
18
19pub fn build_command() -> Command {
20    Command::new("note")
21        .about("Manage notes (free-form text memory entries)")
22        .subcommand_required(true)
23        .subcommand(
24            Command::new("create")
25                .about("Create a new note")
26                .arg(Arg::new("title").required(true).help("Note title"))
27                .arg(
28                    Arg::new("content")
29                        .required(true)
30                        .help("Note content"),
31                )
32                .arg(
33                    Arg::new("note-type")
34                        .long("type")
35                        .default_value("fact")
36                        .help("Note type (fact, observation, decision, lesson, question, hypothesis, principle, insight, preference, reference, context)"),
37                )
38                .arg(
39                    Arg::new("ttl")
40                        .long("ttl")
41                        .default_value("persistent")
42                        .help("Time-to-live (persistent, session, scope, ephemeral, short_term, medium_term, long_term)"),
43                )
44                .arg(
45                    Arg::new("trajectory-id")
46                        .long("trajectory-id")
47                        .help("Source trajectory ID"),
48                ),
49        )
50        .subcommand(
51            Command::new("list")
52                .about("List notes")
53                .arg(
54                    Arg::new("note-type")
55                        .long("type")
56                        .help("Filter by note type"),
57                )
58                .arg(
59                    Arg::new("trajectory-id")
60                        .long("trajectory-id")
61                        .help("Filter by source trajectory"),
62                )
63                .arg(
64                    Arg::new("limit")
65                        .long("limit")
66                        .default_value("20")
67                        .help("Max results"),
68                )
69                .arg(
70                    Arg::new("offset")
71                        .long("offset")
72                        .default_value("0")
73                        .help("Offset"),
74                ),
75        )
76        .subcommand(
77            Command::new("get")
78                .about("Get note details")
79                .arg(Arg::new("id").required(true).help("Note ID")),
80        )
81        .subcommand(
82            Command::new("update")
83                .about("Update a note")
84                .arg(Arg::new("id").required(true).help("Note ID"))
85                .arg(Arg::new("title").long("title").help("New title"))
86                .arg(
87                    Arg::new("content")
88                        .long("content")
89                        .help("New content"),
90                )
91                .arg(
92                    Arg::new("note-type")
93                        .long("type")
94                        .help("New note type"),
95                ),
96        )
97        .subcommand(
98            Command::new("delete")
99                .about("Delete a note")
100                .arg(Arg::new("id").required(true).help("Note ID")),
101        )
102        .subcommand(
103            Command::new("search")
104                .about("Search notes by content")
105                .arg(
106                    Arg::new("query")
107                        .required(true)
108                        .help("Search query text"),
109                )
110                .arg(
111                    Arg::new("limit")
112                        .long("limit")
113                        .default_value("10")
114                        .help("Max results"),
115                )
116                .arg(
117                    Arg::new("vector")
118                        .long("vector")
119                        .action(clap::ArgAction::SetTrue)
120                        .help("Use vector (semantic) search instead of keyword"),
121                ),
122        )
123}
124
125pub async fn dispatch(
126    matches: &ArgMatches,
127    client: &ApiClient,
128    output: &OutputConfig,
129    session: &crate::session::CliSession,
130) -> Result<()> {
131    match matches.subcommand() {
132        Some(("create", sub)) => {
133            let note_type: NoteType = sub
134                .get_one::<String>("note-type")
135                .unwrap()
136                .parse()
137                .map_err(|e: String| anyhow!(e))?;
138            let ttl = parse_ttl(sub.get_one::<String>("ttl").unwrap())?;
139            let source_trajectory_ids =
140                super::resolve_arg(sub, "trajectory-id", session.trajectory_id.as_deref())
141                    .map(|s| -> Result<Vec<_>> { Ok(vec![s.parse()?]) })
142                    .transpose()?
143                    .unwrap_or_default();
144
145            let req = CreateNoteRequest {
146                note_type,
147                title: sub.get_one::<String>("title").unwrap().clone(),
148                content: sub.get_one::<String>("content").unwrap().clone(),
149                source_trajectory_ids,
150                source_artifact_ids: vec![],
151                source_note_ids: vec![],
152                ttl,
153                metadata: None,
154            };
155            let resp: NoteResponse = client.post(BASE, &req).await?;
156            output.print(&resp);
157        }
158        Some(("list", sub)) => {
159            let limit: i64 = sub.get_one::<String>("limit").unwrap().parse()?;
160            let offset: i64 = sub.get_one::<String>("offset").unwrap().parse()?;
161            let mut path = format!("{BASE}?limit={limit}&offset={offset}");
162            if let Some(note_type) = sub.get_one::<String>("note-type") {
163                path.push_str(&format!("&note_type={note_type}"));
164            }
165            if let Some(tid) =
166                super::resolve_arg(sub, "trajectory-id", session.trajectory_id.as_deref())
167            {
168                path.push_str(&format!("&source_trajectory_id={tid}"));
169            }
170            let resp: ListNotesResponse = client.get(&path).await?;
171            output.print_list(&resp.notes, resp.total);
172        }
173        Some(("get", sub)) => {
174            let id = sub.get_one::<String>("id").unwrap();
175            let resp: NoteResponse = client.get(&format!("{BASE}/{id}")).await?;
176            output.print(&resp);
177        }
178        Some(("update", sub)) => {
179            let id = sub.get_one::<String>("id").unwrap();
180            let req = UpdateNoteRequest {
181                title: sub.get_one::<String>("title").cloned(),
182                content: sub.get_one::<String>("content").cloned(),
183                note_type: sub
184                    .get_one::<String>("note-type")
185                    .map(|s| s.parse().map_err(|e: String| anyhow!(e)))
186                    .transpose()?,
187                ttl: None,
188                metadata: None,
189            };
190            let resp: NoteResponse = client.patch(&format!("{BASE}/{id}"), &req).await?;
191            output.print(&resp);
192        }
193        Some(("delete", sub)) => {
194            let id = sub.get_one::<String>("id").unwrap();
195            client.delete(&format!("{BASE}/{id}")).await?;
196            if output.json {
197                println!(r#"{{"deleted": "{id}"}}"#);
198            } else {
199                println!("Deleted note {id}");
200            }
201        }
202        Some(("search", sub)) => {
203            let query = sub.get_one::<String>("query").unwrap().clone();
204            let limit: i64 = sub.get_one::<String>("limit").unwrap().parse()?;
205            let use_vector = sub.get_flag("vector");
206            let req = SearchRequest {
207                query,
208                entity_types: vec![EntityType::Note],
209                filters: vec![],
210                limit: Some(limit),
211                use_vector_search: if use_vector { Some(true) } else { None },
212            };
213            let resp: SearchResponse = client.post(&format!("{BASE}/search"), &req).await?;
214            output.print_list(&resp.results, resp.total);
215        }
216        _ => unreachable!("subcommand_required(true) prevents this"),
217    }
218    Ok(())
219}
220
221/// Parse a TTL string into the TTL enum.
222///
223/// Handles the named variants; Duration and Max are not exposed via CLI
224/// (use the API directly for those).
225fn parse_ttl(s: &str) -> Result<TTL> {
226    match s.to_lowercase().as_str() {
227        "persistent" | "permanent" => Ok(TTL::Persistent),
228        "session" => Ok(TTL::Session),
229        "scope" => Ok(TTL::Scope),
230        "ephemeral" => Ok(TTL::Ephemeral),
231        "short_term" | "short-term" => Ok(TTL::ShortTerm),
232        "medium_term" | "medium-term" => Ok(TTL::MediumTerm),
233        "long_term" | "long-term" => Ok(TTL::LongTerm),
234        _ => anyhow::bail!(
235            "invalid TTL: '{s}' (try: persistent, session, scope, ephemeral, short_term, medium_term, long_term)"
236        ),
237    }
238}