jot_note/
lib.rs

1use edit::{edit_file, Builder};
2use miette::Diagnostic;
3use owo_colors::OwoColorize;
4use std::{fs, io, io::Write, path::PathBuf};
5use thiserror::Error;
6use walkdir::WalkDir;
7
8#[cfg(test)]
9mod tests {
10    use super::*;
11
12    #[test]
13    fn title_from_empty_string() {
14        assert_eq!(title_from_content(""), None);
15    }
16
17    #[test]
18    fn title_from_content_string() {
19        assert_eq!(
20            title_from_content("# some title"),
21            Some("some title".to_string())
22        );
23    }
24
25    #[test]
26    fn title_from_content_no_title() {
27        assert_eq!(title_from_content("# "), None);
28    }
29}
30
31/// Errors that can occur during the operation of the `jot` CLI application.
32///
33/// This enum represents various error types, categorized into:
34/// - **I/O errors** (`IoError`)
35/// - **Temporary file creation errors** (`TempfileCreationError`)
36/// - **Temporary file persistence errors** (`TempfileKeepError`)
37///
38/// The `JotVarietyError` is built using the [`thiserror`](https://docs.rs/thiserror) crate
39/// for structured error composition and utilizes the [`miette`](https://docs.rs/miette) library
40/// for generating user-friendly diagnostics.
41#[derive(Error, Diagnostic, Debug)]
42pub enum JotVarietyError {
43    #[error(transparent)]
44    #[diagnostic(code(jot::io_error))]
45    IoError(#[from] std::io::Error),
46
47    #[error("failed to create tempfile: {0}")]
48    #[diagnostic(code(jot::tempfile_create_error))]
49    TempfileCreationError(std::io::Error),
50
51    #[error("failed to keep tempfile: {0}")]
52    #[diagnostic(code(jot::tempfile_keep_error))]
53    TempfileKeepError(#[from] tempfile::PersistError),
54}
55
56fn ask_for_filename() -> io::Result<String> {
57    rprompt::prompt_reply(
58        "Enter filename\
59        > "
60        .blue()
61        .bold(),
62    )
63}
64
65/// Confirms or modifies an initial filename by interacting with the user in the terminal.
66///
67/// This function prompts the user with the current title (`raw_title`) and asks whether they want to keep it
68/// or provide a new title. If the user confirms the title or provides valid input for a new title, the function
69/// returns the final, confirmed filename as a `String`.
70///
71/// # Arguments
72///
73/// * `raw_title` - A string slice that represents the initial or default filename suggestion.
74///
75/// # Returns
76///
77/// This function returns a `Result<String, std::io::Error>`:
78///
79/// * `Ok(String)` - Contains the confirmed or newly provided title.
80/// * `Err(io::Error)` - Propagates any errors encountered during the prompt interaction with the user.
81///
82/// # Behavior
83///
84/// 1. The user is prompted with:
85///    ```text
86///    current title: {raw_title}
87///    Do you want a different title? (y/N):
88///    ```
89///    - The current title is displayed in bold green (styled for terminal output).
90///    - The default choice is `N` (indicating no change to the title).
91///
92/// 2. User inputs are handled as follows:
93///    - `y` or `Y`: The user opts to change the title, and the function invokes [`ask_for_filename`]
94///      to allow them to input a new filename.
95///    - `n`, `N`, or an empty input: The user confirms the current title, and it is returned unchanged.
96///    - Any other input will repeat the prompt.
97///
98/// 3. If the user chooses to change the title, [`ask_for_filename`] is called to prompt for a new title.
99///
100fn confirm_filename(raw_title: &str) -> io::Result<String> {
101    loop {
102        // prompt defaults to uppercase charcter in question
103        // this is a convention not a req
104        let result = rprompt::prompt_reply(&format!(
105            "current title: {}
106Do you want a different title? (y/{}): ",
107            &raw_title.bold().green(),
108            "N".bold()
109        ))?;
110
111        match result.as_str() {
112            "y" | "Y" => break ask_for_filename(),
113            "n" | "N" | "" => {
114                break Ok(raw_title.to_string());
115            }
116            _ => {
117                // ask again something has gone wrong
118            }
119        }
120    }
121}
122
123/// Extracts the title from the given input string by searching for a Markdown-style header.
124///
125/// This function looks for the first line in the input string that starts with
126/// the Markdown header prefix `# `. If such a line is found, and it contains
127/// non-empty content, the function will return the title as a `String`.
128///
129/// # Arguments
130///
131/// * `input` - A string slice containing the content to extract a title from.
132///
133/// # Returns
134///
135/// * `Option<String>` - Returns `Some(String)` containing the title if a valid header is found,
136/// or `None` if no header is found or the header is empty.
137///
138fn title_from_content(input: &str) -> Option<String> {
139    input.lines().find_map(|line| {
140        line.strip_prefix("# ")
141            .and_then(|title| (!title.is_empty()).then_some(title.to_string()))
142    })
143}
144
145/// Creates a new markdown file in the specified directory and allows the user to edit its contents.
146///
147/// This function performs the following operations:
148/// 1. Creates a temporary markdown file in the specified `jot_path` directory.
149/// 2. Writes a basic markdown template header, using the provided `title` (if any).
150/// 3. Opens the file in the user's `$EDITOR` for editing.
151/// 4. Reads the file content and determines a final filename from the title or user input.
152/// 5. Saves the file with a unique name in the specified directory.
153///
154/// # Arguments
155///
156/// * `jot_path` - A [`PathBuf`](https://doc.rust-lang.org/std/path/struct.PathBuf.html) specifying the directory
157///   where the new file will be created.
158/// * `title` - An optional title to set the initial header of the markdown document. If not provided,
159///   the template will use an empty title, and one can be inferred from the content later.
160///
161/// # Returns
162///
163/// Returns [`Result<(), std::io::Error>`](https://doc.rust-lang.org/std/result/enum.Result.html) which:
164/// - On success, returns `Ok(())`.
165/// - On error, propagates any kind of I/O-related errors, such as file creation or rename failures.
166///
167/// # Behavior
168///
169/// 1. The function creates a temporary file with a `.md` suffix and a random name.
170///    The file is created within the `jot_path` directory.
171/// 2. A markdown template header is written to the file. If a `title` is provided, it is used as the main header;
172///    otherwise, an empty header is used.
173/// 3. The temporary file is opened in the user's default editor by invoking `edit_file`.
174/// 4. After editing, the function reads the file’s contents to determine the title:
175///    - If a `title` was already provided, it uses it directly.
176///    - If no title was provided, the function looks for the first markdown-style header (`#`) in the content
177///      using [`title_from_content`].
178/// 5. The user is then prompted to confirm or modify the resulting filename using [`confirm_filename`].
179/// 6. The file is renamed and saved to the final location:
180///    - Filename conflicts are automatically resolved by appending `-001`, `-002`, etc., to the new filename.
181///
182/// # Error Handling
183///
184/// This function might fail due to:
185/// - File creation issues in the provided `jot_path`.
186/// - Failure to open the file for editing or rename the file on save.
187/// - Failures during user prompts if they involve I/O errors.
188///
189pub fn write(jot_path: PathBuf, title: Option<String>) -> Result<(), std::io::Error> {
190    let (mut file, filepath) = Builder::new()
191        .suffix(".md")
192        .rand_bytes(5)
193        .tempfile_in(&jot_path)?
194        .keep()?;
195    let template = format!("# {}", title.as_deref().unwrap_or(""));
196    file.write_all(template.as_bytes())?;
197    edit_file(&filepath)?;
198    let contents = fs::read_to_string(&filepath)?;
199    let document_title = title.or_else(|| title_from_content(&contents));
200
201    let filename = match document_title {
202        Some(raw_title) => confirm_filename(&raw_title),
203        None => ask_for_filename(),
204    }
205    .map(|title| slug::slugify(title))?;
206
207    for attempt in 0.. {
208        let mut dest = jot_path.join(if attempt == 0 {
209            filename.clone()
210        } else {
211            format!("{filename}{:03}", -attempt)
212        });
213        dest.set_extension("md");
214        if dest.exists() {
215            continue;
216        }
217        fs::rename(filepath, &dest)?;
218        break;
219    }
220
221    Ok(())
222}
223
224/// Appends a message to a scratch file named `_scratch.md` in the given directory.
225///
226/// This function creates or appends to a file named `_scratch.md` in the specified
227/// `jot_path`. The append operation makes it suitable for jotting down quick
228/// notes or reminders. If the `_scratch.md` file does not exist, it will
229/// automatically be created.
230///
231/// # Arguments
232///
233/// * `jot_path` - A `PathBuf` specifying the directory where the `_scratch.md`
234///   file is located or will be created.
235/// * `message` - The `String` content to be added as a new line in the `_scratch.md` file.
236///
237/// # Returns
238///
239/// This function returns a `Result`:
240/// - `Ok(())` on success, indicating the message has been written to the file.
241/// - `Err(std::io::Error)` if there is an issue creating or writing to the file.
242///
243/// # Errors
244///
245/// This function will return an error in situations such as:
246/// - The `jot_path` directory is not accessible.
247/// - There is an error creating or opening `_scratch.md` file.
248/// - Writing the message to the file fails.
249///
250pub fn scratch(jot_path: PathBuf, message: String) -> Result<(), std::io::Error> {
251    let mut scratch_path = jot_path.join("_scratch");
252    scratch_path.set_extension("md");
253
254    let mut scratch_file = fs::OpenOptions::new()
255        .create(true)
256        .append(true)
257        .open(scratch_path)?;
258    write!(&mut scratch_file, "\n{}", message)?;
259    Ok(())
260}
261
262/// Opens an existing markdown file in the specified `jot_path` directory by its title.
263///
264/// If the file exists, it will be opened using the provided `edit_file` function. If
265/// the file does not exist, an error message will be displayed to the user.
266///
267/// # Arguments
268///
269/// * `jot_path` - A [`PathBuf`] representing the directory where the notes are stored.
270/// * `title` - A [`String`] containing the name of the file to open (without the extension).
271///
272/// # Returns
273///
274/// Returns a [`Result`] with `Ok(())` if the operation succeeds, or an [`std::io::Error`]
275/// if an error occurs.
276///
277/// # Behavior
278///
279/// 1. Joins the `jot_path` and the `title` to generate the file's path.
280/// 2. Sets the file's extension to `.md`.
281/// 3. If the file is found:
282///     - Opens the file using the `edit_file` function.
283/// 4. If the file is not found:
284///     - Prints an error message to the user with the file path.
285/// 5. Debug information about the file path is logged using `dbg!`.
286///
287pub fn open(jot_path: PathBuf, title: String) -> Result<(), std::io::Error> {
288    let mut open_path = jot_path.join(title);
289    open_path.set_extension("md");
290
291    if open_path.is_file() {
292        edit_file(&open_path)?;
293    } else {
294        println!(
295            "{} - {}",
296            "File not found".bold().red(),
297            &open_path.display()
298        );
299    }
300    Ok(())
301}
302
303/// Searches recursively within a given directory for files containing a specific query string.
304///
305/// # Arguments
306///
307/// * `jot_path` - A [`PathBuf`] representing the directory to search.
308/// * `query` - A [Vec<`String`>] representing the text to search for within the files.
309///
310/// # Return
311///
312/// Returns an [`Ok(())`] result if the function completes successfully. If an error occurs
313/// during file reading or traversal, it returns an [`Err`] with the specific [`std::io::Error`].
314///
315/// # Behavior
316///
317/// - The function iterates over all files in the directory specified by `jot_path`, including
318///   subdirectories.
319/// - All text files are read, and their contents are checked for the presence of the `query` string.
320/// - If a file contains the query, the path to that file is printed in bold green text.
321/// - If no files contain the query, a "No results found" message is displayed in bold red text.
322///
323/// # Errors
324///
325/// - Returns an error if the directory cannot be read,
326///   or if the contents of any file fail to be read.
327///
328pub fn search(jot_path: PathBuf, query: Vec<String>) -> Result<(), std::io::Error> {
329    let mut counter = 0;
330    for entry in WalkDir::new(jot_path)
331        .into_iter()
332        .filter_map(|e| e.ok().and_then(|e2| e2.path().is_file().then_some(e2)))
333    {
334        let content = fs::read_to_string(&entry.path())?;
335        if query.iter().any(|q| content.contains(q)) {
336            println!("{}", entry.path().display().bold().green());
337            counter += 1;
338        }
339    }
340    // Counter is zero so that must mean the search didn't find anything
341    if counter == 0 {
342        println!("{}", "No results found".bold().red());
343    }
344    Ok(())
345}
346
347/// Lists all files in the specified directory and prints them to the console.
348///
349/// This function traverses the provided `jot_path` directory
350/// (and its subdirectories) to find all files, excluding directories.
351/// It then prints out the file paths in a bold green font for easy readability.
352///
353/// # Arguments
354///
355/// * `jot_path` - A [`PathBuf`] representing the directory where the notes are stored.
356///
357/// # Returns
358///
359/// Returns a [`Result`] with `Ok(())` if the operation completes successfully,
360/// or an [`std::io::Error`] if an error occurs during file traversal.
361///
362/// # Behavior
363///
364/// 1. Recursively iterates through the `jot_path` directory using [`WalkDir`].
365/// 2. Filters out directories, keeping only files.
366/// 3. For each file:
367///     - Prints the file path to the console, formatted in bold green text using [`owo-colors`].
368///
369
370pub fn list(jot_path: PathBuf) -> Result<(), std::io::Error> {
371    for entry in WalkDir::new(jot_path)
372        .into_iter()
373        .filter_map(|e| e.ok().and_then(|e2| e2.path().is_file().then_some(e2)))
374    {
375        println!("{}", entry.path().display().bold().green());
376    }
377    Ok(())
378}