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