Skip to main content

kimun_notes/cli/commands/
note_ops.rs

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