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