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}