1use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::NoteVault;
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 Overwrite {
44 path: String,
46 content: Option<String>,
48 #[arg(long)]
50 force: bool,
51 },
52 Replace {
54 path: String,
56 old: String,
58 new: String,
60 #[arg(long)]
62 all: bool,
63 #[arg(long)]
65 regex: bool,
66 #[arg(long)]
68 preview: bool,
69 },
70 Prop {
72 #[command(subcommand)]
73 subcommand: super::properties::PropSubcommand,
74 },
75 Delete {
77 path: String,
79 #[arg(long)]
81 force: bool,
82 },
83}
84
85pub async fn run(
86 subcommand: NoteSubcommand,
87 vault: &NoteVault,
88 quick_note_path: &str,
89 workspace_name: &str,
90) -> Result<()> {
91 match subcommand {
92 NoteSubcommand::Create { path, content } => {
93 run_create(vault, &path, content, quick_note_path).await
94 }
95 NoteSubcommand::Append { path, content } => {
96 run_append(vault, &path, content, quick_note_path).await
97 }
98 NoteSubcommand::Quick { content } => run_quick(vault, content).await,
99 NoteSubcommand::Triage => run_triage(vault).await,
100 NoteSubcommand::Show { paths, format } => {
101 use std::io::IsTerminal;
102 let reader = if std::io::stdin().is_terminal() {
103 None
104 } else {
105 Some(std::io::BufReader::new(std::io::stdin().lock()))
106 };
107 let resolved = resolve_show_paths(paths, reader)?;
108 run_show(vault, &resolved, quick_note_path, format, workspace_name).await
109 }
110 NoteSubcommand::Overwrite {
111 path,
112 content,
113 force,
114 } => run_overwrite(vault, &path, content, force, quick_note_path).await,
115 NoteSubcommand::Replace {
116 path,
117 old,
118 new,
119 all,
120 regex,
121 preview,
122 } => {
123 run_replace(
124 vault,
125 &path,
126 &old,
127 &new,
128 all,
129 regex,
130 preview,
131 quick_note_path,
132 )
133 .await
134 }
135 NoteSubcommand::Delete { path, force } => {
136 run_delete(vault, &path, force, quick_note_path).await
137 }
138 NoteSubcommand::Prop { subcommand } => {
139 super::properties::run(subcommand, vault, quick_note_path).await
140 }
141 }
142}
143
144async fn run_overwrite(
145 vault: &NoteVault,
146 path_input: &str,
147 content: Option<String>,
148 force: bool,
149 quick_note_path: &str,
150) -> Result<()> {
151 use crate::cli::helpers::{resolve_content, resolve_note_path};
152
153 if !force {
154 return Err(color_eyre::eyre::eyre!(
155 "Refusing to overwrite without --force (this discards the existing note body)"
156 ));
157 }
158 let vault_path = resolve_note_path(path_input, quick_note_path)?;
159 let text = resolve_content(content)?;
160 if text.is_empty() {
161 return Err(color_eyre::eyre::eyre!(
162 "Refusing to overwrite with empty content (this would wipe the note); pass content, or use `note delete` to remove it"
163 ));
164 }
165
166 vault.save_note(&vault_path, &text).await?;
170
171 println!("Note saved: {}", vault_path);
172 Ok(())
173}
174
175#[allow(clippy::too_many_arguments)]
176async fn run_replace(
177 vault: &NoteVault,
178 path_input: &str,
179 old: &str,
180 new: &str,
181 all: bool,
182 regex: bool,
183 preview: bool,
184 quick_note_path: &str,
185) -> Result<()> {
186 use crate::cli::helpers::resolve_note_path;
187
188 let vault_path = resolve_note_path(path_input, quick_note_path)?;
189
190 if preview {
191 let pv = vault
192 .preview_replace(&vault_path, old, new, all, regex)
193 .await?;
194 eprintln!(
197 "{} occurrence(s) would be replaced in {} (preview — not written)",
198 pv.count, vault_path
199 );
200 print!("{}", pv.content);
201 return Ok(());
202 }
203
204 let count = vault
205 .replace_in_note(&vault_path, old, new, all, regex)
206 .await?;
207
208 println!("Replaced {} occurrence(s) in {}", count, vault_path);
209 Ok(())
210}
211
212async fn run_delete(
213 vault: &NoteVault,
214 path_input: &str,
215 force: bool,
216 quick_note_path: &str,
217) -> Result<()> {
218 use crate::cli::helpers::resolve_note_path;
219
220 if !force {
221 return Err(color_eyre::eyre::eyre!(
222 "Refusing to delete without --force"
223 ));
224 }
225 let vault_path = resolve_note_path(path_input, quick_note_path)?;
226
227 vault.delete_note(&vault_path).await?;
228
229 println!("Note deleted: {}", vault_path);
230 Ok(())
231}
232
233async fn run_create(
234 vault: &NoteVault,
235 path_input: &str,
236 content: Option<String>,
237 quick_note_path: &str,
238) -> Result<()> {
239 use crate::cli::helpers::{resolve_content, resolve_note_path};
240
241 let vault_path = resolve_note_path(path_input, quick_note_path)?;
242 let text = resolve_content(content)?;
243
244 vault.create_note(&vault_path, &text).await?;
245
246 println!("Note saved: {}", vault_path);
247 Ok(())
248}
249
250async fn run_append(
251 vault: &NoteVault,
252 path_input: &str,
253 content: Option<String>,
254 quick_note_path: &str,
255) -> Result<()> {
256 use crate::cli::helpers::{resolve_content, resolve_note_path};
257
258 let vault_path = resolve_note_path(path_input, quick_note_path)?;
259 let text = resolve_content(content)?;
260
261 if text.is_empty() {
262 return Ok(());
263 }
264
265 vault.append_to_note(&vault_path, &text, None).await?;
266
267 println!("Note saved: {}", vault_path);
268 Ok(())
269}
270
271pub(crate) fn format_note_show_text(
272 path: &kimun_core::nfs::VaultPath,
273 content: &str,
274 title: &str,
275 tags: &[String],
276 links: &[String],
277 backlinks: &[String],
278) -> String {
279 let mut out = String::new();
280 out.push_str(&format!("Path: {}\n", path));
281 if !title.is_empty() {
282 out.push_str(&format!("Title: {}\n", title));
283 }
284 if !tags.is_empty() {
285 out.push_str(&format!("Tags: {}\n", tags.join(" ")));
286 }
287 if !links.is_empty() {
288 out.push_str(&format!("Links: {}\n", links.join(", ")));
289 }
290 if !backlinks.is_empty() {
291 out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
292 }
293 out.push_str("---\n");
294 out.push_str(content);
295 out
296}
297
298fn resolve_show_paths<R: std::io::BufRead>(
303 args: Vec<String>,
304 reader: Option<R>,
305) -> color_eyre::eyre::Result<Vec<String>> {
306 if !args.is_empty() {
307 return Ok(args);
308 }
309 match reader {
310 Some(r) => {
311 let paths: Result<Vec<String>, _> = r
312 .lines()
313 .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
314 .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
315 .collect();
316 let paths =
317 paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
318 if paths.is_empty() {
319 return Err(color_eyre::eyre::eyre!(
320 "No paths provided — pass paths as arguments or pipe from stdin"
321 ));
322 }
323 Ok(paths)
324 }
325 None => Err(color_eyre::eyre::eyre!(
326 "No paths provided — pass paths as arguments or pipe from stdin"
327 )),
328 }
329}
330
331async fn run_show(
332 vault: &NoteVault,
333 path_inputs: &[String],
334 quick_note_path: &str,
335 format: crate::cli::output::OutputFormat,
336 workspace_name: &str,
337) -> Result<()> {
338 use crate::cli::helpers::resolve_note_path;
339 use crate::cli::json_output::{
340 JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
341 };
342 use crate::cli::output::OutputFormat;
343 use chrono::Utc;
344
345 if matches!(format, OutputFormat::Paths) {
346 return Err(color_eyre::eyre::eyre!(
347 "--format paths is not valid for note show; use 'text' or 'json'"
348 ));
349 }
350
351 enum Accumulator {
353 Text(Vec<String>),
354 Json(Vec<JsonNoteEntry>),
355 }
356
357 let mut acc = match format {
358 OutputFormat::Text => Accumulator::Text(Vec::new()),
359 OutputFormat::Json => Accumulator::Json(Vec::new()),
360 OutputFormat::Paths => unreachable!("guarded above"),
361 };
362 let mut had_errors = false;
363
364 for input in path_inputs {
365 let vault_path = match resolve_note_path(input, quick_note_path) {
366 Ok(p) => p,
367 Err(e) => {
368 eprintln!("Error: {}", e);
369 had_errors = true;
370 continue;
371 }
372 };
373
374 let note_details = match vault.load_note(&vault_path).await {
375 Ok(nd) => nd,
376 Err(e) if e.is_not_found() => {
380 eprintln!(
381 "Error: {}",
382 e.user_message().unwrap_or_else(|| e.to_string())
383 );
384 had_errors = true;
385 continue;
386 }
387 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
388 };
389
390 let content = ¬e_details.raw_text;
391 let content_data = note_details.get_content_data();
392
393 let backlink_results = vault
394 .get_backlinks(&vault_path)
395 .await
396 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
397 let backlink_paths: Vec<String> = backlink_results
398 .iter()
399 .map(|(e, _)| e.path.to_string())
400 .collect();
401
402 match &mut acc {
403 Accumulator::Text(entries) => {
404 let meta = kimun_core::note::NoteMetadata::of(content);
406 entries.push(format_note_show_text(
407 &vault_path,
408 content,
409 &content_data.title,
410 &meta.tags,
411 &meta.links,
412 &backlink_paths,
413 ));
414 }
415 Accumulator::Json(entries) => {
416 let entry_data = vault
417 .note_entry(&vault_path)
418 .await
419 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
420 let journal_date = vault
421 .journal_date(&vault_path)
422 .map(|d| d.format("%Y-%m-%d").to_string());
423 entries.push(JsonNoteEntry {
424 path: vault_path.to_string_with_ext(),
425 title: content_data.title.clone(),
426 content: content.clone(),
427 size: entry_data.size,
428 modified: entry_data.modified_secs,
429 created: entry_data.modified_secs, hash: format!("{:x}", content_data.hash),
431 journal_date,
432 metadata: JsonNoteMetadata::from_content(content),
433 backlinks: if backlink_paths.is_empty() {
434 None
435 } else {
436 Some(backlink_paths)
437 },
438 });
439 }
440 }
441 }
442
443 let is_empty = match &acc {
444 Accumulator::Text(v) => v.is_empty(),
445 Accumulator::Json(v) => v.is_empty(),
446 };
447 if is_empty {
448 return Err(color_eyre::eyre::eyre!(
449 "No notes found — all specified paths were missing"
450 ));
451 }
452
453 match acc {
457 Accumulator::Text(entries) => {
458 let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
459 print!("{}", entries.join(&sep));
460 }
461 Accumulator::Json(notes) => {
462 let output = JsonOutput {
463 metadata: JsonOutputMetadata {
464 workspace: workspace_name.to_string(),
465 workspace_path: vault.workspace_path().to_string(),
466 total_results: notes.len(),
467 query: None,
468 is_listing: false,
469 generated_at: Utc::now().to_rfc3339(),
470 },
471 notes,
472 };
473 print!(
474 "{}",
475 serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
476 );
477 }
478 }
479
480 if had_errors {
481 return Err(color_eyre::eyre::eyre!(
482 "One or more notes could not be found"
483 ));
484 }
485
486 Ok(())
487}
488
489async fn run_triage(vault: &NoteVault) -> Result<()> {
490 let inbox_notes = vault
491 .get_notes(vault.inbox_path(), false)
492 .await
493 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
494
495 if inbox_notes.is_empty() {
496 println!("Inbox is empty.");
497 return Ok(());
498 }
499
500 println!("Inbox notes ({}):\n", inbox_notes.len());
501 for (entry, content_data) in &inbox_notes {
502 let title = if content_data.title.trim().is_empty() {
503 "<no title>"
504 } else {
505 &content_data.title
506 };
507 println!(" {} — {}", entry.path, title);
508 }
509
510 Ok(())
511}
512
513async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
514 use crate::cli::helpers::resolve_content;
515
516 let text = resolve_content(content)?;
517 if text.is_empty() {
518 return Ok(());
519 }
520
521 let details = vault
522 .quick_note(&text)
523 .await
524 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
525
526 println!("Note saved: {}", details.path);
527 Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532 use super::resolve_show_paths;
533 use std::io::Cursor;
534
535 #[test]
536 fn test_resolve_show_paths_uses_args_when_given() {
537 let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
538 let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
539 assert_eq!(result, args);
540 }
541
542 #[test]
543 fn test_resolve_show_paths_reads_from_reader() {
544 let input = b"projects/foo\ninbox/bar\n";
545 let reader = Cursor::new(input.as_ref());
546 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
547 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
548 }
549
550 #[test]
551 fn test_resolve_show_paths_skips_blank_lines() {
552 let input = b"projects/foo\n\n \ninbox/bar\n";
553 let reader = Cursor::new(input.as_ref());
554 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
555 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
556 }
557
558 #[test]
559 fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
560 let input = b"\n \n\t\n";
561 let reader = Cursor::new(input.as_ref());
562 let result = resolve_show_paths(vec![], Some(reader));
563 assert!(result.is_err());
564 let msg = result.unwrap_err().to_string();
565 assert!(msg.contains("No paths provided"), "got: {}", msg);
566 }
567
568 #[test]
569 fn test_resolve_show_paths_strips_tab_separated_fields() {
570 let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
572 let reader = Cursor::new(input.as_ref());
573 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
574 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
575 }
576
577 #[test]
578 fn test_resolve_show_paths_no_args_no_reader_errors() {
579 let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
580 assert!(result.is_err());
581 let msg = result.unwrap_err().to_string();
582 assert!(msg.contains("No paths provided"), "got: {}", msg);
583 }
584}