1use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::{NoteVault, error::VaultError};
8
9const NOTE_SEPARATOR: &str =
10 "================================================================================";
11
12#[derive(Subcommand, Debug)]
13pub enum NoteSubcommand {
14 Create {
16 path: String,
18 content: Option<String>,
20 },
21 Append {
23 path: String,
25 content: Option<String>,
27 },
28 Quick {
30 content: Option<String>,
32 },
33 Triage,
35 Show {
37 paths: Vec<String>,
39 #[arg(long, value_enum, default_value = "text")]
40 format: crate::cli::output::OutputFormat,
41 },
42}
43
44pub async fn run(
45 subcommand: NoteSubcommand,
46 vault: &NoteVault,
47 quick_note_path: &str,
48 workspace_name: &str,
49) -> Result<()> {
50 match subcommand {
51 NoteSubcommand::Create { path, content } => {
52 run_create(vault, &path, content, quick_note_path).await
53 }
54 NoteSubcommand::Append { path, content } => {
55 run_append(vault, &path, content, quick_note_path).await
56 }
57 NoteSubcommand::Quick { content } => run_quick(vault, content).await,
58 NoteSubcommand::Triage => run_triage(vault).await,
59 NoteSubcommand::Show { paths, format } => {
60 use std::io::IsTerminal;
61 let reader = if std::io::stdin().is_terminal() {
62 None
63 } else {
64 Some(std::io::BufReader::new(std::io::stdin().lock()))
65 };
66 let resolved = resolve_show_paths(paths, reader)?;
67 run_show(vault, &resolved, quick_note_path, format, workspace_name).await
68 }
69 }
70}
71
72async fn run_create(
73 vault: &NoteVault,
74 path_input: &str,
75 content: Option<String>,
76 quick_note_path: &str,
77) -> Result<()> {
78 use crate::cli::helpers::{resolve_content, resolve_note_path};
79
80 let vault_path = resolve_note_path(path_input, quick_note_path)?;
81 let text = resolve_content(content)?;
82
83 vault
84 .create_note(&vault_path, &text)
85 .await
86 .map_err(|e| match &e {
87 VaultError::NoteExists { path } => {
88 color_eyre::eyre::eyre!("Note already exists: {}", path)
89 }
90 _ => color_eyre::eyre::eyre!("{}", e),
91 })?;
92
93 println!("Note saved: {}", vault_path);
94 Ok(())
95}
96
97async fn run_append(
98 vault: &NoteVault,
99 path_input: &str,
100 content: Option<String>,
101 quick_note_path: &str,
102) -> Result<()> {
103 use crate::cli::helpers::{resolve_content, resolve_note_path};
104 use kimun_core::error::FSError;
105
106 let vault_path = resolve_note_path(path_input, quick_note_path)?;
107 let text = resolve_content(content)?;
108
109 if text.is_empty() {
110 return Ok(());
111 }
112
113 match vault.get_note_text(&vault_path).await {
114 Ok(existing) => {
115 let combined = format!("{}\n{}", existing, text);
116 vault
117 .save_note(&vault_path, &combined)
118 .await
119 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
120 }
121 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
122 match vault.create_note(&vault_path, &text).await {
123 Ok(_) => {}
124 Err(VaultError::NoteExists { .. }) => {
125 let existing = vault
127 .get_note_text(&vault_path)
128 .await
129 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
130 let combined = format!("{}\n{}", existing, text);
131 vault
132 .save_note(&vault_path, &combined)
133 .await
134 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
135 }
136 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
137 }
138 }
139 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
140 }
141
142 println!("Note saved: {}", vault_path);
143 Ok(())
144}
145
146pub(crate) fn format_note_show_text(
147 path: &kimun_core::nfs::VaultPath,
148 content: &str,
149 title: &str,
150 tags: &[String],
151 links: &[String],
152 backlinks: &[String],
153) -> String {
154 let mut out = String::new();
155 out.push_str(&format!("Path: {}\n", path));
156 if !title.is_empty() {
157 out.push_str(&format!("Title: {}\n", title));
158 }
159 if !tags.is_empty() {
160 out.push_str(&format!("Tags: {}\n", tags.join(" ")));
161 }
162 if !links.is_empty() {
163 out.push_str(&format!("Links: {}\n", links.join(", ")));
164 }
165 if !backlinks.is_empty() {
166 out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
167 }
168 out.push_str("---\n");
169 out.push_str(content);
170 out
171}
172
173fn resolve_show_paths<R: std::io::BufRead>(
178 args: Vec<String>,
179 reader: Option<R>,
180) -> color_eyre::eyre::Result<Vec<String>> {
181 if !args.is_empty() {
182 return Ok(args);
183 }
184 match reader {
185 Some(r) => {
186 let paths: Result<Vec<String>, _> = r
187 .lines()
188 .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
189 .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
190 .collect();
191 let paths =
192 paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
193 if paths.is_empty() {
194 return Err(color_eyre::eyre::eyre!(
195 "No paths provided — pass paths as arguments or pipe from stdin"
196 ));
197 }
198 Ok(paths)
199 }
200 None => Err(color_eyre::eyre::eyre!(
201 "No paths provided — pass paths as arguments or pipe from stdin"
202 )),
203 }
204}
205
206async fn run_show(
207 vault: &NoteVault,
208 path_inputs: &[String],
209 quick_note_path: &str,
210 format: crate::cli::output::OutputFormat,
211 workspace_name: &str,
212) -> Result<()> {
213 use crate::cli::helpers::resolve_note_path;
214 use crate::cli::json_output::{
215 JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
216 };
217 use crate::cli::metadata_extractor::{extract_headers, extract_links, extract_tags};
218 use crate::cli::output::OutputFormat;
219 use chrono::Utc;
220 use kimun_core::error::{FSError, VaultError};
221 use kimun_core::nfs::NoteEntryData;
222 use std::time::UNIX_EPOCH;
223
224 if matches!(format, OutputFormat::Paths) {
225 return Err(color_eyre::eyre::eyre!(
226 "--format paths is not valid for note show; use 'text' or 'json'"
227 ));
228 }
229
230 enum Accumulator {
232 Text(Vec<String>),
233 Json(Vec<JsonNoteEntry>),
234 }
235
236 let mut acc = match format {
237 OutputFormat::Text => Accumulator::Text(Vec::new()),
238 OutputFormat::Json => Accumulator::Json(Vec::new()),
239 OutputFormat::Paths => unreachable!("guarded above"),
240 };
241 let mut had_errors = false;
242
243 for input in path_inputs {
244 let vault_path = match resolve_note_path(input, quick_note_path) {
245 Ok(p) => p,
246 Err(e) => {
247 eprintln!("Error: {}", e);
248 had_errors = true;
249 continue;
250 }
251 };
252
253 let note_details = match vault.load_note(&vault_path).await {
254 Ok(nd) => nd,
255 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
256 eprintln!("Error: Note not found: {}", vault_path);
257 had_errors = true;
258 continue;
259 }
260 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
261 };
262
263 let content = ¬e_details.raw_text;
264 let content_data = note_details.get_content_data();
265
266 let backlink_results = vault
267 .get_backlinks(&vault_path)
268 .await
269 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
270 let backlink_paths: Vec<String> = backlink_results
271 .iter()
272 .map(|(e, _)| e.path.to_string())
273 .collect();
274
275 match &mut acc {
276 Accumulator::Text(entries) => {
277 let tags = extract_tags(content);
278 let links = extract_links(content);
279 entries.push(format_note_show_text(
280 &vault_path,
281 content,
282 &content_data.title,
283 &tags,
284 &links,
285 &backlink_paths,
286 ));
287 }
288 Accumulator::Json(entries) => {
289 let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
290 .await
291 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
292 let modified_secs = meta
293 .modified()
294 .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
295 .unwrap_or(0);
296 let entry_data = NoteEntryData {
297 path: vault_path.clone(),
298 size: meta.len(),
299 modified_secs,
300 };
301 let tags = extract_tags(content);
302 let links = extract_links(content);
303 let headers = extract_headers(content);
304 let journal_date = vault
305 .journal_date(&vault_path)
306 .map(|d| d.format("%Y-%m-%d").to_string());
307 entries.push(JsonNoteEntry {
308 path: vault_path.to_string_with_ext(),
309 title: content_data.title.clone(),
310 content: content.clone(),
311 size: entry_data.size,
312 modified: entry_data.modified_secs,
313 created: entry_data.modified_secs, hash: format!("{:x}", content_data.hash),
315 journal_date,
316 metadata: JsonNoteMetadata {
317 tags,
318 links,
319 headers,
320 },
321 backlinks: if backlink_paths.is_empty() {
322 None
323 } else {
324 Some(backlink_paths)
325 },
326 });
327 }
328 }
329 }
330
331 let is_empty = match &acc {
332 Accumulator::Text(v) => v.is_empty(),
333 Accumulator::Json(v) => v.is_empty(),
334 };
335 if is_empty {
336 return Err(color_eyre::eyre::eyre!(
337 "No notes found — all specified paths were missing"
338 ));
339 }
340
341 match acc {
345 Accumulator::Text(entries) => {
346 let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
347 print!("{}", entries.join(&sep));
348 }
349 Accumulator::Json(notes) => {
350 let output = JsonOutput {
351 metadata: JsonOutputMetadata {
352 workspace: workspace_name.to_string(),
353 workspace_path: vault.workspace_path().to_string_lossy().to_string(),
354 total_results: notes.len(),
355 query: None,
356 is_listing: false,
357 generated_at: Utc::now().to_rfc3339(),
358 },
359 notes,
360 };
361 print!(
362 "{}",
363 serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
364 );
365 }
366 }
367
368 if had_errors {
369 return Err(color_eyre::eyre::eyre!(
370 "One or more notes could not be found"
371 ));
372 }
373
374 Ok(())
375}
376
377async fn run_triage(vault: &NoteVault) -> Result<()> {
378 let inbox_notes = vault
379 .get_notes(vault.inbox_path(), false)
380 .await
381 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
382
383 if inbox_notes.is_empty() {
384 println!("Inbox is empty.");
385 return Ok(());
386 }
387
388 println!("Inbox notes ({}):\n", inbox_notes.len());
389 for (entry, content_data) in &inbox_notes {
390 let title = if content_data.title.trim().is_empty() {
391 "<no title>"
392 } else {
393 &content_data.title
394 };
395 println!(" {} — {}", entry.path, title);
396 }
397
398 Ok(())
399}
400
401async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
402 use crate::cli::helpers::resolve_content;
403
404 let text = resolve_content(content)?;
405 if text.is_empty() {
406 return Ok(());
407 }
408
409 let details = vault
410 .quick_note(&text)
411 .await
412 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
413
414 println!("Note saved: {}", details.path);
415 Ok(())
416}
417
418#[cfg(test)]
419mod tests {
420 use super::resolve_show_paths;
421 use std::io::Cursor;
422
423 #[test]
424 fn test_resolve_show_paths_uses_args_when_given() {
425 let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
426 let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
427 assert_eq!(result, args);
428 }
429
430 #[test]
431 fn test_resolve_show_paths_reads_from_reader() {
432 let input = b"projects/foo\ninbox/bar\n";
433 let reader = Cursor::new(input.as_ref());
434 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
435 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
436 }
437
438 #[test]
439 fn test_resolve_show_paths_skips_blank_lines() {
440 let input = b"projects/foo\n\n \ninbox/bar\n";
441 let reader = Cursor::new(input.as_ref());
442 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
443 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
444 }
445
446 #[test]
447 fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
448 let input = b"\n \n\t\n";
449 let reader = Cursor::new(input.as_ref());
450 let result = resolve_show_paths(vec![], Some(reader));
451 assert!(result.is_err());
452 let msg = result.unwrap_err().to_string();
453 assert!(msg.contains("No paths provided"), "got: {}", msg);
454 }
455
456 #[test]
457 fn test_resolve_show_paths_strips_tab_separated_fields() {
458 let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
460 let reader = Cursor::new(input.as_ref());
461 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
462 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
463 }
464
465 #[test]
466 fn test_resolve_show_paths_no_args_no_reader_errors() {
467 let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
468 assert!(result.is_err());
469 let msg = result.unwrap_err().to_string();
470 assert!(msg.contains("No paths provided"), "got: {}", msg);
471 }
472}