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