1use 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!("¬e_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
221fn 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}