Skip to main content

nb_api/
lib.rs

1//! Typed Rust interface to the `nb` note-taking CLI.
2//!
3//! Handles notebook qualification, escaping, and output parsing.
4//! Wraps the `nb` CLI as a subprocess, providing async methods for
5//! all note-taking operations.
6
7mod git;
8
9use std::{collections::VecDeque, path::PathBuf, process::Stdio, sync::LazyLock};
10
11use regex::Regex;
12use serde::Deserialize;
13use tokio::process::Command;
14
15pub use git::{derive_git_notebook_name, git_rev_parse};
16
17/// Regex to match ANSI/ISO 2022 escape sequences.
18///
19/// Covers:
20/// - Fe sequences: `ESC [@-Z\-_]` (single byte after ESC)
21/// - CSI sequences: `ESC [ ... m` (SGR colors, cursor control, etc.)
22/// - nF sequences: `ESC [ -/]* [0-~]` (character set designation like `ESC ( B`)
23static ANSI_REGEX: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])").unwrap());
25
26/// Strip ANSI escape sequences from text.
27fn strip_ansi(text: &str) -> String {
28    ANSI_REGEX.replace_all(text, "").into_owned()
29}
30
31/// Errors from nb CLI invocation.
32#[derive(Debug, thiserror::Error)]
33pub enum NbError {
34    #[error("nb command failed: {0}")]
35    CommandFailed(String),
36
37    #[error(
38        "nb not found in PATH; install via: brew install xwmx/taps/nb (macOS) or see https://github.com/xwmx/nb#installation"
39    )]
40    NotFound,
41
42    #[error("IO error: {0}")]
43    Io(#[from] std::io::Error),
44}
45
46/// Configuration for constructing an [`NbClient`].
47///
48/// Contains only nb-relevant fields. MCP-specific fields
49/// (e.g., `show_paths`) remain in the server's config.
50#[derive(Clone, Debug)]
51pub struct Config {
52    /// Default notebook name (overrides Git-derived fallback).
53    pub notebook: Option<String>,
54    /// Automatically create missing notebooks.
55    pub create_notebook: bool,
56    /// Allow new notes to be created at notebook root.
57    pub allow_top_level_notes: bool,
58    /// Disable Git commit and tag signing for `nb` subprocesses.
59    pub disable_git_signing: bool,
60}
61
62impl Default for Config {
63    fn default() -> Self {
64        Self {
65            notebook: None,
66            create_notebook: true,
67            allow_top_level_notes: false,
68            disable_git_signing: false,
69        }
70    }
71}
72
73/// Client for invoking nb commands.
74#[derive(Clone)]
75pub struct NbClient {
76    /// Default notebook to use if not specified per-command.
77    default_notebook: Option<String>,
78    /// Automatically create missing notebooks.
79    create_notebook: bool,
80    /// Disable Git commit and tag signing for `nb` subprocesses.
81    disable_git_signing: bool,
82    /// Allow new notes to be created at notebook root.
83    allow_top_level_notes: bool,
84}
85
86const FOLDER_REQUIRED_MESSAGE: &str = "This server is configured to require `folder` for new notes. Use the `nb.mkdir` tool to create new folders and the `nb.folders` tool to list existing folders.";
87
88const NOTEBOOK_FIELD_MESSAGE: &str = "Invalid `notebook`: use a bare notebook name only. Use `folder` for folder paths and `id`/`selector` for note selectors.";
89
90const FOLDER_FIELD_MESSAGE: &str = "Invalid folder path: use `folder` for folder paths only, not notebook-qualified selectors. To choose a notebook, use the separate `notebook` field.";
91
92/// Behavior mode for `nb edit` content updates.
93#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
94#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
95#[serde(rename_all = "lowercase")]
96pub enum EditMode {
97    /// Replace note content using `nb edit --overwrite`.
98    #[default]
99    Replace,
100    /// Append content using `nb edit --content` (nb default behavior).
101    Append,
102    /// Prepend content using `nb edit --prepend`.
103    Prepend,
104}
105
106/// Matching mode for `nb search` query terms.
107#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
108#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
109#[serde(rename_all = "lowercase")]
110pub enum SearchMode {
111    /// Match any query term (`OR` semantics).
112    #[default]
113    Any,
114    /// Require all query terms (`AND` semantics).
115    All,
116}
117
118/// Status filter for `nb tasks`.
119#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[serde(rename_all = "lowercase")]
122pub enum TaskStatus {
123    /// Return open tasks.
124    Open,
125    /// Return closed tasks.
126    Closed,
127}
128
129impl NbClient {
130    /// Creates a new nb client.
131    ///
132    /// Uses the notebook from config if set, otherwise falls back to a
133    /// Git-derived notebook name. Does NOT read `NB_MCP_NOTEBOOK` —
134    /// that is an MCP-server-specific env var resolved by the server.
135    pub fn new(config: &Config) -> anyhow::Result<Self> {
136        let default_notebook = config
137            .notebook
138            .as_deref()
139            .map(String::from)
140            .or_else(derive_git_notebook_name);
141        Ok(Self {
142            default_notebook,
143            create_notebook: config.create_notebook,
144            disable_git_signing: config.disable_git_signing,
145            allow_top_level_notes: config.allow_top_level_notes,
146        })
147    }
148
149    fn require_folder_for_new_note(&self, folder: Option<&str>) -> Result<(), NbError> {
150        if self.allow_top_level_notes || folder.is_some_and(|value| !value.trim().is_empty()) {
151            return Ok(());
152        }
153        Err(NbError::CommandFailed(FOLDER_REQUIRED_MESSAGE.to_string()))
154    }
155
156    async fn resolve_target_selector(
157        &self,
158        id: &str,
159        notebook: Option<&str>,
160    ) -> Result<(String, String), NbError> {
161        if let Some((embedded_notebook, path)) = parse_qualified_selector(id)? {
162            let notebook = match notebook {
163                Some(value) => {
164                    validate_notebook_name(value)?;
165                    if value != embedded_notebook {
166                        return Err(NbError::CommandFailed(format!(
167                            "ambiguous selector: id targets notebook `{embedded_notebook}`, but notebook field is `{value}`"
168                        )));
169                    }
170                    embedded_notebook.to_string()
171                }
172                _ => embedded_notebook.to_string(),
173            };
174            self.ensure_existing_notebook(&notebook).await?;
175            return Ok((notebook, format!("{}:{}", embedded_notebook, path)));
176        }
177        let notebook = self.resolve_notebook(notebook).await?;
178        Ok((notebook.clone(), format!("{}:{}", notebook, id)))
179    }
180
181    fn append_notebook_warning(&self, output: String, notebook: &str) -> String {
182        let Some(default_notebook) = self.default_notebook.as_deref() else {
183            return output;
184        };
185        if default_notebook == notebook {
186            return output;
187        }
188        append_warning(
189            output,
190            format!(
191                "Warning: wrote to notebook `{notebook}`, not the project default notebook `{default_notebook}`. If this was unintended, move or delete the note and retry with the correct notebook/folder."
192            ),
193        )
194    }
195
196    /// Resolves the notebook to use for a command.
197    fn resolve_notebook_name(&self, notebook: Option<&str>) -> Result<String, NbError> {
198        if let Some(name) = notebook {
199            validate_notebook_name(name)?;
200            return Ok(name.to_string());
201        }
202        if let Some(name) = self.default_notebook.as_deref() {
203            validate_notebook_name(name)?;
204            return Ok(name.to_string());
205        }
206        Err(NbError::CommandFailed(
207            "notebook not configured; set --notebook or NB_MCP_NOTEBOOK".to_string(),
208        ))
209    }
210
211    async fn resolve_notebook(&self, notebook: Option<&str>) -> Result<String, NbError> {
212        let name = self.resolve_notebook_name(notebook)?;
213        self.ensure_notebook(&name).await?;
214        Ok(name)
215    }
216
217    async fn ensure_notebook(&self, notebook: &str) -> Result<(), NbError> {
218        match self.check_notebook(notebook).await {
219            Ok(()) => Ok(()),
220            Err(_) => {
221                if !self.create_notebook {
222                    return Err(NbError::CommandFailed(format!(
223                        "notebook not found; create it with the nb CLI (`nb notebooks add {}`) \
224                         or remove --no-create-notebook",
225                        notebook
226                    )));
227                }
228                self.exec_vec(vec![
229                    "notebooks".to_string(),
230                    "add".to_string(),
231                    notebook.to_string(),
232                ])
233                .await?;
234                Ok(())
235            }
236        }
237    }
238
239    async fn ensure_existing_notebook(&self, notebook: &str) -> Result<(), NbError> {
240        self.check_notebook(notebook).await.map_err(|_| {
241            NbError::CommandFailed(format!(
242                "notebook not found: `{notebook}`. Use a copied selector only for an existing notebook."
243            ))
244        })
245    }
246
247    async fn check_notebook(&self, notebook: &str) -> Result<(), NbError> {
248        let show_result = self
249            .exec_vec(vec![
250                "notebooks".to_string(),
251                "show".to_string(),
252                notebook.to_string(),
253                "--path".to_string(),
254            ])
255            .await;
256        match show_result {
257            Ok(output) => {
258                if output.trim().is_empty() {
259                    return Err(NbError::CommandFailed(
260                        "nb notebooks path output was empty".to_string(),
261                    ));
262                }
263                Ok(())
264            }
265            Err(_) => Err(NbError::CommandFailed(format!(
266                "notebook not found: `{notebook}`"
267            ))),
268        }
269    }
270
271    /// Executes an nb command and returns stdout.
272    async fn exec(&self, args: &[&str]) -> Result<String, NbError> {
273        tracing::debug!(?args, "executing nb command");
274        let mut command = Command::new("nb");
275        command
276            .args(args)
277            .stdin(Stdio::null()) // Prevent TTY hangs
278            .stdout(Stdio::piped())
279            .stderr(Stdio::piped());
280        if self.disable_git_signing {
281            apply_git_signing_env(&mut command);
282        }
283        let output = command
284            .spawn()
285            .map_err(|e| {
286                if e.kind() == std::io::ErrorKind::NotFound {
287                    NbError::NotFound
288                } else {
289                    NbError::Io(e)
290                }
291            })?
292            .wait_with_output()
293            .await?;
294
295        if output.status.success() {
296            let stdout = String::from_utf8_lossy(&output.stdout);
297            Ok(strip_ansi(&stdout))
298        } else {
299            let stderr = String::from_utf8_lossy(&output.stderr);
300            let stdout = String::from_utf8_lossy(&output.stdout);
301            // nb sometimes writes errors to stdout
302            let msg = if stderr.is_empty() {
303                strip_ansi(&stdout)
304            } else {
305                strip_ansi(&stderr)
306            };
307            Err(NbError::CommandFailed(msg))
308        }
309    }
310
311    /// Executes an nb command with dynamic arguments.
312    async fn exec_vec(&self, args: Vec<String>) -> Result<String, NbError> {
313        let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
314        self.exec(&args_ref).await
315    }
316
317    /// Returns status information about the resolved notebook.
318    pub async fn status(&self, notebook: Option<&str>) -> Result<String, NbError> {
319        let notebook = self.resolve_notebook(notebook).await?;
320        self.exec_vec(vec![format!("{}:", notebook), "status".to_string()])
321            .await
322    }
323
324    /// Lists available notebooks.
325    pub async fn notebooks(&self) -> Result<String, NbError> {
326        // Use --no-color to avoid ANSI escape codes
327        self.exec(&["notebooks", "--no-color"]).await
328    }
329
330    /// Returns the path for a notebook.
331    pub async fn notebook_path(&self, notebook: Option<&str>) -> Result<PathBuf, NbError> {
332        let notebook = self.resolve_notebook(notebook).await?;
333        let output = self
334            .exec_vec(vec![
335                "notebooks".to_string(),
336                "show".to_string(),
337                notebook,
338                "--path".to_string(),
339            ])
340            .await?;
341        let path = output.trim();
342        if path.is_empty() {
343            return Err(NbError::CommandFailed(
344                "nb notebooks path output was empty".to_string(),
345            ));
346        }
347        Ok(PathBuf::from(path))
348    }
349
350    /// Creates a new note.
351    pub async fn add(
352        &self,
353        title: Option<&str>,
354        content: &str,
355        tags: &[String],
356        folder: Option<&str>,
357        notebook: Option<&str>,
358    ) -> Result<String, NbError> {
359        let mut args = Vec::new();
360        self.require_folder_for_new_note(folder)?;
361        validate_folder_option(folder)?;
362
363        let notebook = self.resolve_notebook(notebook).await?;
364        let cmd = format!("{}:add", notebook);
365        args.push(cmd);
366
367        // Title (if provided)
368        if let Some(t) = title {
369            args.push("--title".to_string());
370            args.push(t.to_string());
371        }
372
373        // Content via --content flag (avoids shell escaping issues)
374        args.push("--content".to_string());
375        args.push(content.to_string());
376
377        // Tags (nb expects #hashtag format)
378        for tag in tags {
379            args.push("--tags".to_string());
380            let tag_str = if tag.starts_with('#') {
381                tag.clone()
382            } else {
383                format!("#{}", tag)
384            };
385            args.push(tag_str);
386        }
387
388        // Folder
389        if let Some(f) = folder {
390            args.push("--folder".to_string());
391            args.push(f.to_string());
392        }
393
394        self.exec_vec(args)
395            .await
396            .map(|output| self.append_notebook_warning(output, &notebook))
397    }
398
399    /// Shows a note's content.
400    pub async fn show(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
401        let (_, selector) = self.resolve_target_selector(id, notebook).await?;
402        self.exec_vec(vec!["show".to_string(), selector, "--no-color".to_string()])
403            .await
404    }
405
406    /// Lists notes in a notebook or folder.
407    pub async fn list(
408        &self,
409        folder: Option<&str>,
410        tags: &[String],
411        limit: Option<u32>,
412        notebook: Option<&str>,
413    ) -> Result<String, NbError> {
414        let mut args = Vec::new();
415        validate_folder_option(folder)?;
416
417        let notebook = self.resolve_notebook(notebook).await?;
418        let cmd = match folder {
419            Some(f) => format!("{}:{}/", notebook, f),
420            None => format!("{}:", notebook),
421        };
422
423        args.push("list".to_string());
424        args.push(cmd);
425
426        // No color for parsing
427        args.push("--no-color".to_string());
428
429        // Limit
430        if let Some(n) = limit {
431            args.push("-n".to_string());
432            args.push(n.to_string());
433        }
434
435        // Tags filter
436        for tag in tags {
437            args.push("--tags".to_string());
438            let tag_str = if tag.starts_with('#') {
439                tag.clone()
440            } else {
441                format!("#{}", tag)
442            };
443            args.push(tag_str);
444        }
445
446        self.exec_vec(args).await
447    }
448
449    /// Searches notes.
450    pub async fn search(
451        &self,
452        queries: &[String],
453        mode: SearchMode,
454        tags: &[String],
455        folder: Option<&str>,
456        notebook: Option<&str>,
457    ) -> Result<String, NbError> {
458        validate_folder_option(folder)?;
459        if queries.is_empty() {
460            return Err(NbError::CommandFailed(
461                "at least one search query is required".to_string(),
462            ));
463        }
464
465        let notebook = self.resolve_notebook(notebook).await?;
466        let scope = match folder {
467            Some(f) => format!("{}:{}/", notebook, f),
468            None => format!("{}:", notebook),
469        };
470        let args = search_command_args(scope, queries, mode, tags);
471        self.exec_vec(args).await
472    }
473
474    /// Edits a note using the provided content mode.
475    pub async fn edit(
476        &self,
477        id: &str,
478        content: &str,
479        mode: EditMode,
480        notebook: Option<&str>,
481    ) -> Result<String, NbError> {
482        let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
483        let output = self.exec_vec(edit_args(selector, content, mode)).await?;
484        Ok(self.append_notebook_warning(output, &notebook))
485    }
486
487    /// Deletes a note.
488    pub async fn delete(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
489        let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
490        let output = self
491            .exec_vec(vec!["delete".to_string(), selector, "--force".to_string()])
492            .await?;
493        Ok(self.append_notebook_warning(output, &notebook))
494    }
495
496    /// Moves or renames a note.
497    pub async fn move_note(
498        &self,
499        id: &str,
500        destination: &str,
501        notebook: Option<&str>,
502    ) -> Result<String, NbError> {
503        validate_destination(destination)?;
504        let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
505        let output = self
506            .exec_vec(vec![
507                "move".to_string(),
508                selector,
509                destination.to_string(),
510                "--force".to_string(),
511            ])
512            .await?;
513        Ok(self.append_notebook_warning(output, &notebook))
514    }
515
516    /// Creates a todo item.
517    pub async fn todo(
518        &self,
519        title: &str,
520        description: Option<&str>,
521        tasks: &[String],
522        tags: &[String],
523        folder: Option<&str>,
524        notebook: Option<&str>,
525    ) -> Result<String, NbError> {
526        self.require_folder_for_new_note(folder)?;
527        validate_folder_option(folder)?;
528        let notebook = self.resolve_notebook(notebook).await?;
529        let output = self
530            .exec_vec(todo_command_args(
531                &notebook,
532                title,
533                description,
534                tasks,
535                tags,
536                folder,
537            ))
538            .await?;
539        Ok(self.append_notebook_warning(output, &notebook))
540    }
541
542    /// Marks a todo as done.
543    pub async fn do_task(
544        &self,
545        id: &str,
546        task_number: Option<u32>,
547        notebook: Option<&str>,
548    ) -> Result<String, NbError> {
549        let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
550        let output = self
551            .exec_vec(task_command_args("do", selector, task_number))
552            .await?;
553        Ok(self.append_notebook_warning(output, &notebook))
554    }
555
556    /// Marks a todo as not done.
557    pub async fn undo_task(
558        &self,
559        id: &str,
560        task_number: Option<u32>,
561        notebook: Option<&str>,
562    ) -> Result<String, NbError> {
563        let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
564        let output = self
565            .exec_vec(task_command_args("undo", selector, task_number))
566            .await?;
567        Ok(self.append_notebook_warning(output, &notebook))
568    }
569
570    /// Lists todos.
571    pub async fn tasks(
572        &self,
573        folder: Option<&str>,
574        status: Option<TaskStatus>,
575        recursive: bool,
576        notebook: Option<&str>,
577    ) -> Result<String, NbError> {
578        validate_folder_option(folder)?;
579        let notebook = self.resolve_notebook(notebook).await?;
580        let folder = folder.map(normalize_folder);
581        let scopes = if recursive {
582            self.tasks_scopes_recursive(&notebook, folder.as_deref())
583                .await?
584        } else {
585            vec![tasks_scope(&notebook, folder.as_deref())]
586        };
587
588        let mut outputs: Vec<String> = Vec::new();
589        let mut saw_empty = false;
590        for scope in scopes {
591            match self.exec_vec(tasks_command_args(scope, status)).await {
592                Ok(output) => {
593                    let output = output.trim();
594                    if !output.is_empty() {
595                        outputs.push(output.to_string());
596                    }
597                }
598                Err(NbError::CommandFailed(message)) if is_empty_tasks_error(&message) => {
599                    saw_empty = true;
600                }
601                Err(err) => return Err(err),
602            }
603        }
604        if outputs.is_empty() && saw_empty {
605            return Err(NbError::CommandFailed(empty_tasks_message(status)));
606        }
607        Ok(outputs.join("\n"))
608    }
609
610    async fn tasks_scopes_recursive(
611        &self,
612        notebook: &str,
613        folder: Option<&str>,
614    ) -> Result<Vec<String>, NbError> {
615        let notebook_root = self.notebook_path(Some(notebook)).await?;
616        let start = folder.unwrap_or_default().to_string();
617        let mut queue = VecDeque::new();
618        queue.push_back(start.clone());
619
620        let mut scopes = vec![tasks_scope(notebook, folder)];
621        while let Some(current) = queue.pop_front() {
622            let base = if current.is_empty() {
623                notebook_root.clone()
624            } else {
625                notebook_root.join(&current)
626            };
627            let children = child_folder_names(&base)?;
628            for child in children {
629                let next = if current.is_empty() {
630                    child
631                } else {
632                    format!("{}/{}", current, child)
633                };
634                scopes.push(tasks_scope(notebook, Some(&next)));
635                queue.push_back(next);
636            }
637        }
638        Ok(scopes)
639    }
640
641    /// Creates a bookmark.
642    pub async fn bookmark(
643        &self,
644        url: &str,
645        title: Option<&str>,
646        tags: &[String],
647        comment: Option<&str>,
648        folder: Option<&str>,
649        notebook: Option<&str>,
650    ) -> Result<String, NbError> {
651        let mut args = Vec::new();
652        self.require_folder_for_new_note(folder)?;
653        validate_folder_option(folder)?;
654
655        // Build the destination path with optional folder
656        let notebook = self.resolve_notebook(notebook).await?;
657        let dest = match folder {
658            Some(f) => format!("{}:{}/", notebook, f),
659            None => format!("{}:", notebook),
660        };
661
662        let cmd = format!("{}bookmark", dest);
663        args.push(cmd);
664        args.push(url.to_string());
665
666        if let Some(t) = title {
667            args.push("--title".to_string());
668            args.push(t.to_string());
669        }
670
671        if let Some(c) = comment {
672            args.push("--comment".to_string());
673            args.push(c.to_string());
674        }
675
676        for tag in tags {
677            args.push("--tags".to_string());
678            let tag_str = if tag.starts_with('#') {
679                tag.clone()
680            } else {
681                format!("#{}", tag)
682            };
683            args.push(tag_str);
684        }
685
686        self.exec_vec(args)
687            .await
688            .map(|output| self.append_notebook_warning(output, &notebook))
689    }
690
691    /// Lists folders in a notebook.
692    pub async fn folders(
693        &self,
694        parent: Option<&str>,
695        notebook: Option<&str>,
696    ) -> Result<String, NbError> {
697        let mut args = vec!["list".to_string()];
698        validate_folder_option(parent)?;
699
700        let notebook = self.resolve_notebook(notebook).await?;
701        let path = match parent {
702            Some(p) => format!("{}:{}/", notebook, p),
703            None => format!("{}:", notebook),
704        };
705        args.push(path);
706
707        // Filter to only show folders
708        args.push("--type".to_string());
709        args.push("folder".to_string());
710        args.push("--no-color".to_string());
711
712        self.exec_vec(args).await
713    }
714
715    /// Creates a folder.
716    pub async fn mkdir(&self, path: &str, notebook: Option<&str>) -> Result<String, NbError> {
717        validate_folder_path(path)?;
718        let notebook = self.resolve_notebook(notebook).await?;
719        let folder_path = mkdir_selector(&notebook, path);
720        let output = self
721            .exec_vec(vec!["add".to_string(), "folder".to_string(), folder_path])
722            .await?;
723        Ok(self.append_notebook_warning(output, &notebook))
724    }
725
726    /// Imports a file or URL into the notebook.
727    pub async fn import(
728        &self,
729        source: &str,
730        folder: Option<&str>,
731        filename: Option<&str>,
732        convert: bool,
733        notebook: Option<&str>,
734    ) -> Result<String, NbError> {
735        let mut args = Vec::new();
736        self.require_folder_for_new_note(folder)?;
737        validate_folder_option(folder)?;
738
739        let notebook = self.resolve_notebook(notebook).await?;
740        let cmd = format!("{}:import", notebook);
741        args.push(cmd);
742
743        // Source path or URL
744        args.push(source.to_string());
745
746        // Convert HTML to Markdown
747        if convert {
748            args.push("--convert".to_string());
749        }
750
751        // Destination: notebook:folder/filename or just folder/filename
752        // nb import expects destination as a positional argument after source
753        if folder.is_some() || filename.is_some() {
754            let dest = match (folder, filename) {
755                (Some(f), Some(n)) => format!("{}/{}", f, n),
756                (Some(f), None) => format!("{}/", f),
757                (None, Some(n)) => n.to_string(),
758                (None, None) => unreachable!(),
759            };
760            args.push(dest);
761        }
762
763        self.exec_vec(args)
764            .await
765            .map(|output| self.append_notebook_warning(output, &notebook))
766    }
767}
768
769fn append_warning(mut output: String, warning: String) -> String {
770    if !output.trim().is_empty() {
771        if !output.ends_with('\n') {
772            output.push('\n');
773        }
774        output.push('\n');
775    }
776    output.push_str(&warning);
777    output
778}
779
780fn validate_notebook_name(name: &str) -> Result<(), NbError> {
781    if name.trim().is_empty() || name.contains(':') || name.contains('/') || name.contains('\\') {
782        return Err(NbError::CommandFailed(NOTEBOOK_FIELD_MESSAGE.to_string()));
783    }
784    Ok(())
785}
786
787fn validate_folder_option(folder: Option<&str>) -> Result<(), NbError> {
788    if let Some(path) = folder {
789        validate_folder_path(path)?;
790    }
791    Ok(())
792}
793
794fn validate_folder_path(path: &str) -> Result<(), NbError> {
795    if path.trim().is_empty() || path.contains(':') {
796        return Err(NbError::CommandFailed(FOLDER_FIELD_MESSAGE.to_string()));
797    }
798    Ok(())
799}
800
801fn validate_destination(destination: &str) -> Result<(), NbError> {
802    if destination.trim().is_empty() || destination.contains(':') {
803        return Err(NbError::CommandFailed(
804            "Invalid destination: use a folder path or filename only, not a notebook-qualified selector."
805                .to_string(),
806        ));
807    }
808    Ok(())
809}
810
811fn parse_qualified_selector(selector: &str) -> Result<Option<(&str, &str)>, NbError> {
812    let Some((notebook, path)) = selector.split_once(':') else {
813        return Ok(None);
814    };
815    validate_notebook_name(notebook)?;
816    if path.trim().is_empty() || path.contains(':') {
817        return Err(NbError::CommandFailed(
818            "Invalid selector: use at most one notebook qualifier, as `<notebook>:<folder>/<id>`."
819                .to_string(),
820        ));
821    }
822    Ok(Some((notebook, path)))
823}
824
825fn edit_args(selector: String, content: &str, mode: EditMode) -> Vec<String> {
826    let mut args = vec!["edit".to_string(), selector];
827    match mode {
828        EditMode::Replace => args.push("--overwrite".to_string()),
829        EditMode::Append => {}
830        EditMode::Prepend => args.push("--prepend".to_string()),
831    }
832    args.push("--content".to_string());
833    args.push(content.to_string());
834    args
835}
836
837fn task_command_args(action: &str, selector: String, task_number: Option<u32>) -> Vec<String> {
838    let mut args = vec![action.to_string(), selector];
839    if let Some(number) = task_number {
840        args.push(number.to_string());
841    }
842    args
843}
844
845fn todo_command_args(
846    notebook: &str,
847    title: &str,
848    description: Option<&str>,
849    tasks: &[String],
850    tags: &[String],
851    folder: Option<&str>,
852) -> Vec<String> {
853    let mut args = vec![format!("{notebook}:todo"), "add".to_string()];
854
855    // Folder path comes as a positional argument before the title.
856    if let Some(folder) = folder {
857        args.push(folder_scope(folder));
858    }
859
860    args.push(title.to_string());
861
862    if let Some(description) = description {
863        args.push("--description".to_string());
864        args.push(description.to_string());
865    }
866
867    for task in tasks {
868        args.push("--task".to_string());
869        args.push(task.to_string());
870    }
871
872    for tag in tags {
873        args.push("--tags".to_string());
874        args.push(normalize_tag(tag));
875    }
876
877    args
878}
879
880fn folder_scope(folder: &str) -> String {
881    if folder.ends_with('/') {
882        folder.to_string()
883    } else {
884        format!("{folder}/")
885    }
886}
887
888fn normalize_tag(tag: &str) -> String {
889    if tag.starts_with('#') {
890        tag.to_string()
891    } else {
892        format!("#{tag}")
893    }
894}
895
896fn normalize_folder(folder: &str) -> String {
897    folder.trim_matches('/').to_string()
898}
899
900fn mkdir_selector(notebook: &str, path: &str) -> String {
901    let normalized = normalize_folder(path);
902    format!("{}:{}", notebook, normalized)
903}
904
905fn tasks_scope(notebook: &str, folder: Option<&str>) -> String {
906    match folder {
907        Some(path) if !path.is_empty() => format!("{}:{}/", notebook, path),
908        _ => format!("{}:", notebook),
909    }
910}
911
912fn tasks_command_args(scope: String, status: Option<TaskStatus>) -> Vec<String> {
913    let mut args = vec!["tasks".to_string(), scope];
914    if let Some(filter) = status {
915        let status = match filter {
916            TaskStatus::Open => "open",
917            TaskStatus::Closed => "closed",
918        };
919        args.push(status.to_string());
920    }
921    args.push("--no-color".to_string());
922    args
923}
924
925fn search_command_args(
926    scope: String,
927    queries: &[String],
928    mode: SearchMode,
929    tags: &[String],
930) -> Vec<String> {
931    let mut args = vec!["search".to_string(), scope];
932    let mut terms = queries.iter();
933    if let Some(first) = terms.next() {
934        args.push(first.to_string());
935    }
936    match mode {
937        SearchMode::Any => {
938            for query in terms {
939                args.push("--or".to_string());
940                args.push(query.to_string());
941            }
942        }
943        SearchMode::All => {
944            for query in terms {
945                args.push(query.to_string());
946            }
947        }
948    }
949    for tag in tags {
950        args.push("--tag".to_string());
951        args.push(normalize_tag(tag));
952    }
953    args.push("--no-color".to_string());
954    args
955}
956
957fn is_empty_tasks_error(message: &str) -> bool {
958    message.trim_start().starts_with("! 0 ") && message.contains(" tasks.")
959}
960
961fn empty_tasks_message(status: Option<TaskStatus>) -> String {
962    match status {
963        Some(TaskStatus::Open) => "! 0 open tasks.".to_string(),
964        Some(TaskStatus::Closed) => "! 0 closed tasks.".to_string(),
965        None => "! 0 tasks.".to_string(),
966    }
967}
968
969fn child_folder_names(path: &std::path::Path) -> Result<Vec<String>, NbError> {
970    let read_dir = match std::fs::read_dir(path) {
971        Ok(entries) => entries,
972        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
973        Err(err) => return Err(NbError::Io(err)),
974    };
975
976    let mut names = Vec::new();
977    for entry in read_dir {
978        let entry = entry?;
979        let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else {
980            continue;
981        };
982        if name.starts_with('.') {
983            continue;
984        }
985        let meta = match entry.metadata() {
986            Ok(meta) => meta,
987            Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
988            Err(err) => return Err(NbError::Io(err)),
989        };
990        if meta.is_dir() {
991            names.push(name);
992        }
993    }
994    names.sort();
995    Ok(names)
996}
997
998const GIT_SIGNING_OVERRIDES: [(&str, &str); 2] =
999    [("commit.gpgsign", "false"), ("tag.gpgsign", "false")];
1000
1001fn git_config_count(raw: Option<&str>) -> usize {
1002    raw.and_then(|value| value.parse::<usize>().ok())
1003        .unwrap_or(0)
1004}
1005
1006fn git_signing_env_vars(start_index: usize) -> Vec<(String, String)> {
1007    let total = start_index.saturating_add(GIT_SIGNING_OVERRIDES.len());
1008    let mut env_vars = Vec::with_capacity(1 + GIT_SIGNING_OVERRIDES.len() * 2);
1009    env_vars.push(("GIT_CONFIG_COUNT".to_string(), total.to_string()));
1010    for (offset, (key, value)) in GIT_SIGNING_OVERRIDES.iter().enumerate() {
1011        let index = start_index + offset;
1012        env_vars.push((format!("GIT_CONFIG_KEY_{index}"), (*key).to_string()));
1013        env_vars.push((format!("GIT_CONFIG_VALUE_{index}"), (*value).to_string()));
1014    }
1015    env_vars
1016}
1017
1018fn apply_git_signing_env(command: &mut Command) {
1019    let start_index = git_config_count(std::env::var("GIT_CONFIG_COUNT").ok().as_deref());
1020    for (name, value) in git_signing_env_vars(start_index) {
1021        command.env(name, value);
1022    }
1023}