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
12#[derive(Subcommand, Debug)]
13pub enum NoteSubcommand {
14    /// Create a new note (fails if the note already exists)
15    Create {
16        /// Note path, relative to quick_note_path or absolute from vault root
17        path: String,
18        /// Note content (reads from stdin if omitted and stdin is not a TTY)
19        content: Option<String>,
20    },
21    /// Append text to a note (creates the note if it does not exist)
22    Append {
23        /// Note path, relative to quick_note_path or absolute from vault root
24        path: String,
25        /// Text to append (reads from stdin if omitted and stdin is not a TTY)
26        content: Option<String>,
27    },
28    /// Quickly capture a thought into a timestamped inbox note
29    Quick {
30        /// Text content (reads from stdin if omitted and stdin is not a TTY)
31        content: Option<String>,
32    },
33    /// List inbox notes for triage
34    Triage,
35    /// Show note content and metadata (read one or more notes)
36    Show {
37        /// One or more note paths (relative to quick_note_path or absolute from vault root)
38        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                    // Race: note created between our get and create — re-read and save
126                    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
173/// Resolves the effective path list for `note show`.
174/// - If `args` is non-empty, returns it directly (reader is ignored).
175/// - If `args` is empty and `reader` is `Some`, reads non-blank trimmed lines from it.
176/// - If `args` is empty and `reader` is `None` (TTY), returns an error.
177fn 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    // One accumulator per format — only the active one is ever populated.
231    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 = &note_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, // TODO: track actual creation time
314                    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    // Output whatever was found — the JSON/text is valid for the notes that succeeded.
342    // had_errors (non-zero exit) signals that some notes were missing; those were
343    // already reported to stderr in the loop above.
344    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        // kimun notes outputs tab-separated lines: path\ttitle\tsize\ttimestamp
459        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}