rusty_commit_saver/vim_commit.rs
1use chrono::DateTime;
2use chrono::Utc;
3use git2::Repository;
4
5use std::env;
6use std::error::Error;
7use std::fs;
8use std::fs::OpenOptions;
9use std::io::Write;
10use std::path::Path;
11use std::path::PathBuf;
12
13use log::debug;
14use log::error;
15use log::info;
16use log::warn;
17
18/// Stores Git commit metadata for logging to Obsidian diary entries.
19///
20/// This struct captures all essential information about a single Git commit
21/// that will be written as a row in the daily diary table. It's automatically
22/// populated from the current Git repository's HEAD commit.
23///
24/// # Examples
25///
26/// ```ignore
27/// use rusty_commit_saver::CommitSaver;
28///
29/// // Automatically populated from current Git repository
30/// let saver = CommitSaver::new();
31///
32/// println!("Repository: {}", saver.repository_url);
33/// println!("Branch: {}", saver.commit_branch_name);
34/// println!("Hash: {}", saver.commit_hash);
35/// println!("Message: {}", saver.commit_msg);
36/// ```
37///
38/// # See Also
39///
40/// - [`CommitSaver::new()`] - Create a new instance from current Git repo
41/// - [`CommitSaver::append_entry_to_diary()`] - Write commit to diary file
42#[derive(Debug, Clone)]
43pub struct CommitSaver {
44 /// The Git remote origin URL.
45 ///
46 /// Retrieved from the repository's `origin` remote. Double quotes are stripped.
47 ///
48 /// # Examples
49 ///
50 /// - `https://github.com/user/repo.git`
51 /// - `git@github.com:user/repo.git`
52 /// - `https://git.sr.ht/~user/repo`
53 pub repository_url: String,
54
55 /// The current Git branch name.
56 ///
57 /// Retrieved from the repository's HEAD reference. Double quotes are stripped.
58 ///
59 /// # Examples
60 ///
61 /// - `main`
62 /// - `develop`
63 /// - `feature/add-documentation`
64 pub commit_branch_name: String,
65
66 /// The full SHA-1 commit hash (40 characters).
67 ///
68 /// Uniquely identifies the commit in the Git repository.
69 ///
70 /// # Format
71 ///
72 /// Always 40 hexadecimal characters (e.g., `abc123def456...`)
73 pub commit_hash: String,
74
75 /// The formatted commit message for Obsidian display.
76 ///
77 /// The message is processed for safe rendering in Markdown tables:
78 /// - Pipe characters (`|`) are escaped to `\|`
79 /// - Multiple lines are joined with `<br/>`
80 /// - Empty lines are filtered out
81 /// - Leading/trailing whitespace is trimmed
82 ///
83 /// # Examples
84 ///
85 /// ```text
86 /// Original: "feat: add feature\n\nWith details"
87 /// Formatted: "feat: add feature<br/>With details"
88 ///
89 /// Original: "fix: issue | problem"
90 /// Formatted: "fix: issue \| problem"
91 /// ```
92 pub commit_msg: String,
93
94 /// The UTC timestamp when the commit was created.
95 ///
96 /// Used for:
97 /// - Generating date-based directory paths
98 /// - Displaying commit time in diary entries
99 /// - Creating frontmatter tags (week number, day of week)
100 ///
101 /// # Format
102 ///
103 /// Stored as `DateTime<Utc>` from the `chrono` crate.
104 pub commit_datetime: DateTime<Utc>,
105}
106
107/// Creates a `CommitSaver` instance with default values from the current Git repository.
108///
109/// This implementation automatically discovers the Git repository in the current directory
110/// and extracts all commit metadata from the HEAD commit. It's the core logic used by
111/// [`CommitSaver::new()`].
112///
113/// # Panics
114///
115/// Panics if:
116/// - No Git repository is found in the current directory or any parent directory
117/// - The repository has no HEAD (uninitialized or corrupted repository)
118/// - The HEAD reference cannot be resolved to a commit
119/// - The remote "origin" doesn't exist
120///
121/// # Commit Message Processing
122///
123/// The commit message undergoes several transformations:
124/// 1. Split into individual lines
125/// 2. Trim whitespace from each line
126/// 3. Escape pipe characters: `|` → `\|` (for Markdown table compatibility)
127/// 4. Filter out empty lines
128/// 5. Join with `<br/>` separator (for Obsidian rendering)
129///
130/// # Examples
131///
132/// ```ignore
133/// use rusty_commit_saver::CommitSaver;
134///
135/// // Using Default trait directly
136/// let saver = CommitSaver::default();
137///
138/// // Equivalent to:
139/// let saver2 = CommitSaver::new();
140/// ```
141impl Default for CommitSaver {
142 fn default() -> CommitSaver {
143 let git_repo = Repository::discover("./").unwrap();
144 let head = git_repo.head().unwrap();
145 let commit = head.peel_to_commit().unwrap();
146 CommitSaver {
147 repository_url: {
148 let url = match git_repo.find_remote("origin") {
149 Ok(bind) => bind.url().unwrap().replace('\"', ""),
150 _ => "no_url_set".to_string(),
151 };
152 url
153 },
154 commit_branch_name: {
155 // head.shorthand().unwrap().replace('\"', "")
156 let branch = match head.shorthand() {
157 Some(branch) => branch.replace('\"', ""),
158 None => "no_branch_set".to_string(),
159 };
160 branch
161 },
162 commit_hash: { commit.id().to_string() },
163 commit_msg: {
164 // Preserve original lines, escape pipes, then join with <br/>
165 let raw = commit.message().unwrap_or("");
166 raw.lines()
167 .map(|line| line.trim().replace('|', "\\|"))
168 .filter(|line| !line.is_empty())
169 .collect::<Vec<_>>()
170 .join("<br/>")
171 },
172 commit_datetime: {
173 let commit_date: i64 = commit.time().seconds();
174 DateTime::from_timestamp(commit_date, 0).unwrap()
175 },
176 }
177 }
178}
179
180impl CommitSaver {
181 /// Creates a new `CommitSaver` instance by discovering the current Git repository.
182 ///
183 /// This function automatically:
184 /// - Discovers the Git repository in the current directory (`.`)
185 /// - Extracts commit metadata from the HEAD commit
186 /// - Formats the commit message for Obsidian (escapes pipes, adds `<br/>`)
187 ///
188 /// # Panics
189 ///
190 /// Panics if:
191 /// - No Git repository is found in the current directory
192 /// - The repository has no HEAD (uninitialized repo)
193 /// - The HEAD cannot be resolved to a commit
194 ///
195 /// # Examples
196 ///
197 /// ```ignore
198 /// use rusty_commit_saver::CommitSaver;
199 ///
200 /// let saver = CommitSaver::new();
201 /// println!("Commit hash: {}", saver.commit_hash);
202 /// ```
203 #[must_use]
204 pub fn new() -> Self {
205 CommitSaver::default()
206 }
207
208 /// Formats commit metadata as a Markdown table row for diary entry.
209 ///
210 /// Generates a single table row containing all commit information in the format
211 /// expected by the Obsidian diary template. The row includes pipe delimiters
212 /// and ends with a newline.
213 ///
214 /// # Arguments
215 ///
216 /// * `path` - The current working directory where the commit was made
217 ///
218 /// # Returns
219 ///
220 /// A formatted string representing one table row with these columns:
221 /// 1. **FOLDER** - Current working directory path
222 /// 2. **TIME** - Commit timestamp (HH:MM:SS format)
223 /// 3. **COMMIT MESSAGE** - Escaped and formatted commit message
224 /// 4. **REPOSITORY URL** - Git remote origin URL
225 /// 5. **BRANCH** - Current branch name
226 /// 6. **COMMIT HASH** - Full SHA-1 commit hash
227 ///
228 /// # Format
229 ///
230 /// ```text
231 /// | /path/to/repo | 14:30:45 | feat: add feature | https://github.com/user/repo.git | main | abc123... |
232 /// ```
233 ///
234 /// # Note
235 ///
236 /// This is a private helper method called by [`append_entry_to_diary()`](Self::append_entry_to_diary).
237 /// The commit message has already been formatted with escaped pipes and `<br/>` separators
238 /// during struct initialization.
239 fn prepare_commit_entry_as_string(&mut self, path: &Path) -> String {
240 format!(
241 "| {:} | {:} | {:} | {:} | {:} | {:} |\n",
242 path.display(),
243 self.commit_datetime.format("%H:%M:%S"),
244 self.commit_msg,
245 self.repository_url,
246 self.commit_branch_name,
247 self.commit_hash
248 )
249 }
250
251 /// Generates Obsidian-style frontmatter tags based on the commit timestamp.
252 ///
253 /// Creates three metadata tags for organizing diary entries:
254 /// 1. **Week tag**: `#datetime/week/WW` (e.g., `#datetime/week/02` for week 2)
255 /// 2. **Day tag**: `#datetime/days/DDDD` (e.g., `#datetime/days/Monday`)
256 /// 3. **Category tag**: `#diary/commits` (constant)
257 ///
258 /// These tags are used in the Obsidian diary file's YAML frontmatter to enable:
259 /// - Filtering commits by week number
260 /// - Organizing by day of week
261 /// - Cross-referencing with other diary entries
262 ///
263 /// # Returns
264 ///
265 /// A vector of three strings containing formatted Obsidian tags
266 ///
267 /// # Examples
268 ///
269 /// ```ignore
270 /// use rusty_commit_saver::CommitSaver;
271 /// use chrono::{TimeZone, Utc};
272 ///
273 /// let mut saver = CommitSaver {
274 /// repository_url: "https://github.com/example/repo.git".to_string(),
275 /// commit_branch_name: "main".to_string(),
276 /// commit_hash: "abc123".to_string(),
277 /// commit_msg: "feat: add feature".to_string(),
278 /// commit_datetime: Utc.with_ymd_and_hms(2025, 1, 13, 10, 30, 0).unwrap(), // Monday
279 /// };
280 ///
281 /// let tags = saver.prepare_frontmatter_tags();
282 /// assert_eq!(tags.len(), 3);
283 /// assert!(tags.contains("week"));
284 /// assert!(tags.contains("Monday"));[1]
285 /// assert_eq!(tags, "#diary/commits");
286 /// ```
287 pub fn prepare_frontmatter_tags(&mut self) -> Vec<String> {
288 info!("[CommitSaver::prepare_frontmatter_tags()]: Preparing the frontmatter week number.");
289 let week_number = format!("#datetime/week/{:}", self.commit_datetime.format("%W"));
290
291 info!("[CommitSaver::prepare_frontmatter_tags()]: Preparing the frontmatter week day.");
292 let week_day = format!("#datetime/days/{:}", self.commit_datetime.format("%A"));
293
294 info!(
295 "[CommitSaver::prepare_frontmatter_tags()]: Returing the formatted vector with the frontmatter tags week number and day."
296 );
297 vec![week_number, week_day, "#diary/commits".to_string()]
298 }
299
300 /// Constructs the full file path for a diary entry based on the commit timestamp.
301 ///
302 /// Combines the Obsidian commit directory path with a date-formatted subdirectory structure
303 /// to create the final path where the commit entry should be saved.
304 ///
305 /// # Arguments
306 ///
307 /// * `obsidian_commit_path` - Base directory path for commits (e.g., `Diaries/Commits`)
308 /// * `template_commit_date_path` - Chrono format string for the date hierarchy (e.g., `%Y/%m-%B/%F.md`)
309 ///
310 /// # Returns
311 ///
312 /// A formatted path string combining the base directory and formatted date
313 ///
314 /// # Format Specifiers (Chrono)
315 ///
316 /// - `%Y` - Year (e.g., `2025`)
317 /// - `%m` - Month as number (e.g., `01`)
318 /// - `%B` - Full month name (e.g., `January`)
319 /// - `%F` - ISO 8601 date (e.g., `2025-01-14.md`)
320 /// - `%d` - Day of month (e.g., `14`)
321 ///
322 /// # Panics
323 ///
324 /// Panics if:
325 /// - The `obsidian_commit_path` cannot be converted to a valid UTF-8 string
326 /// - The path contains invalid characters that cannot be represented as a string
327 ///
328 /// # Examples
329 ///
330 /// ```ignore
331 /// use rusty_commit_saver::CommitSaver;
332 /// use std::path::PathBuf;
333 /// use chrono::{TimeZone, Utc};
334 ///
335 /// let mut saver = CommitSaver {
336 /// repository_url: "https://github.com/example/repo.git".to_string(),
337 /// commit_branch_name: "main".to_string(),
338 /// commit_hash: "abc123".to_string(),
339 /// commit_msg: "feat: add feature".to_string(),
340 /// commit_datetime: Utc.with_ymd_and_hms(2025, 1, 14, 10, 30, 0).unwrap(),
341 /// };
342 ///
343 /// let path = saver.prepare_path_for_commit(
344 /// &PathBuf::from("Diaries/Commits"),
345 /// "%Y/%m-%B/%F.md"
346 /// );
347 /// // Returns: "/Diaries/Commits/2025/01-January/2025-01-14.md"
348 /// assert!(path.contains("2025"));
349 /// assert!(path.contains("January"));
350 /// assert!(path.contains("2025-01-14.md"));
351 /// ```
352 pub fn prepare_path_for_commit(
353 &mut self,
354 obsidian_commit_path: &Path,
355 template_commit_date_path: &str,
356 ) -> String {
357 info!("[CommitSaver::prepare_path_for_commit()]: Preparing the path for commit file.");
358 let commit_path = obsidian_commit_path
359 .as_os_str()
360 .to_str()
361 .expect("asd")
362 .to_string();
363
364 info!("[CommitSaver::prepare_path_for_commit()]: Retrieving the path for commit file.");
365 let paths_with_dates_and_file =
366 self.prepare_date_for_commit_file(template_commit_date_path);
367
368 info!(
369 "[CommitSaver::prepare_path_for_commit()]: Returning the full String of the ComitPath and File."
370 );
371 format!("/{commit_path:}/{paths_with_dates_and_file:}")
372 }
373
374 /// Formats the commit timestamp using a Chrono date format string.
375 ///
376 /// Applies the given format template to the commit's datetime to generate
377 /// a date-based directory path or filename. This enables flexible organization
378 /// of diary entries by year, month, week, or custom hierarchies.
379 ///
380 /// # Arguments
381 ///
382 /// * `path_format` - Chrono format string (e.g., `%Y/%m-%B/%F.md`)
383 ///
384 /// # Returns
385 ///
386 /// A formatted date string suitable for file paths
387 ///
388 /// # Common Format Specifiers
389 ///
390 /// - `%Y` - Year (4 digits, e.g., `2025`)
391 /// - `%m` - Month (2 digits, e.g., `01`)
392 /// - `%B` - Full month name (e.g., `January`)
393 /// - `%b` - Abbreviated month (e.g., `Jan`)
394 /// - `%d` - Day of month (2 digits, e.g., `14`)
395 /// - `%F` - ISO 8601 date format (`%Y-%m-%d`, e.g., `2025-01-14`)
396 /// - `%A` - Full weekday name (e.g., `Monday`)
397 /// - `%W` - Week number (e.g., `02`)
398 ///
399 /// # Examples
400 ///
401 /// ```text
402 /// // With format "%Y/%m-%B/%F.md" and datetime 2025-01-14:
403 /// // Returns: "2025/01-January/2025-01-14.md"
404 ///
405 /// // With format "%Y/week-%W/%F.md" and datetime in week 2:
406 /// // Returns: "2025/week-02/2025-01-14.md"
407 /// ```
408 ///
409 /// # Note
410 ///
411 /// This is a private helper method called by [`prepare_path_for_commit()`](Self::prepare_path_for_commit).
412 fn prepare_date_for_commit_file(&mut self, path_format: &str) -> String {
413 info!(
414 "[CommitSaver::prepare_date_for_commit_file()]: Formatting commit path with DateTime."
415 );
416 // %B July Full month name. Also accepts corresponding abbreviation in parsing.
417 // %F 2001-07-08 Year-month-day format (ISO 8601). Same as %Y-%m-%d.
418 self.commit_datetime.format(path_format).to_string()
419 }
420
421 /// Appends the current commit as a table row to an Obsidian diary file.
422 ///
423 /// This method writes a formatted commit entry to the specified diary file in append mode.
424 /// The entry includes: current directory, timestamp, commit message, repository URL, branch, and commit hash.
425 ///
426 /// # Arguments
427 ///
428 /// * `wiki` - Path to the diary file where the commit entry should be appended
429 ///
430 /// # Returns
431 ///
432 /// - `Ok(())` - Successfully appended the commit entry to the file
433 /// - `Err(Box<dyn Error>)` - If file operations fail (file doesn't exist, permission denied, etc.)
434 ///
435 /// # Errors
436 ///
437 /// Returns an error if:
438 /// - The diary file cannot be opened for appending
439 /// - The current working directory cannot be determined
440 /// - File write operations fail (I/O error, permission denied)
441 ///
442 /// # Examples
443 ///
444 /// ```ignore
445 /// use rusty_commit_saver::CommitSaver;
446 /// use std::path::PathBuf;
447 ///
448 /// let mut saver = CommitSaver::new();
449 /// let diary_path = PathBuf::from("/home/user/diary/2025-01-14.md");
450 ///
451 /// match saver.append_entry_to_diary(&diary_path) {
452 /// Ok(()) => println!("Commit logged successfully!"),
453 /// Err(e) => eprintln!("Failed to log commit: {}", e),
454 /// }
455 /// ```
456 pub fn append_entry_to_diary(&mut self, wiki: &PathBuf) -> Result<(), Box<dyn Error>> {
457 info!("[CommitSaver::append_entry_to_diary()]: Getting current directory.");
458 let path = env::current_dir()?;
459
460 info!("[CommitSaver::append_entry_to_diary()]: Preparing the commit_entry_as_string.");
461 let new_commit_str = self.prepare_commit_entry_as_string(&path);
462
463 debug!("[CommitSaver::append_entry_to_diary()]: Commit String: {new_commit_str:}");
464 debug!(
465 "[CommitSaver::append_entry_to_diary()]: Wiki:\n{:}",
466 wiki.display()
467 );
468 let mut file_ref = OpenOptions::new().append(true).open(wiki)?;
469
470 file_ref.write_all(new_commit_str.as_bytes())?;
471
472 Ok(())
473 }
474}
475
476// Markup template for generating Obsidian diary file structure.
477//
478// This macro defines the template for new diary entry files, including:
479// - YAML frontmatter with metadata and tags
480// - Main heading with the date
481// - Markdown table header for commit entries
482//
483// Used internally by create_diary_file().
484markup::define! {
485 DiaryFileEntry(frontmatter: Vec<String>, diary_date: String) {
486"---
487category: diary\n
488section: commits\n
489tags:\n"
490@for tag in frontmatter.iter() {
491"- '" @tag "'\n"
492}
493"date: " @diary_date
494"\n
495---
496\n
497# " @diary_date
498"\n
499| FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
500|--------|------|----------------|----------------|--------|-------------|\n"
501 }
502}
503
504/// Extracts the parent directory from a file path.
505///
506/// Returns a reference to the parent directory component of the given path.
507/// This is useful for creating parent directories before writing a file.
508///
509/// # Arguments
510///
511/// * `full_diary_path` - A file path to extract the parent directory from
512///
513/// # Returns
514///
515/// - `Ok(&Path)` - Reference to the parent directory
516/// - `Err(Box<dyn Error>)` - If the path has no parent (e.g., root directory `/`)
517///
518/// # Errors
519///
520/// Returns an error if:
521/// - The path is the root directory (has no parent)
522/// - The path is a relative single component with no parent
523///
524/// # Examples
525///
526/// ```ignore
527/// use rusty_commit_saver::vim_commit::get_parent_from_full_path;
528/// use std::path::{Path, PathBuf};
529///
530/// // Normal nested path
531/// let path = Path::new("/home/user/documents/diary.md");
532/// let parent = get_parent_from_full_path(path).unwrap();
533/// assert_eq!(parent, Path::new("/home/user/documents"));
534///
535/// // Deep nesting
536/// let deep = Path::new("/a/b/c/d/e/f/file.txt");
537/// let parent = get_parent_from_full_path(deep).unwrap();
538/// assert_eq!(parent, Path::new("/a/b/c/d/e/f"));
539///
540/// // Root directory fails
541/// let root = Path::new("/");
542/// assert!(get_parent_from_full_path(root).is_err());
543/// ```
544pub fn get_parent_from_full_path(full_diary_path: &Path) -> Result<&Path, Box<dyn Error>> {
545 info!(
546 "[get_parent_from_full_path()] Checking if there is parents for: {:}.",
547 full_diary_path.display()
548 );
549 if let Some(dir) = full_diary_path.parent() {
550 Ok(dir)
551 } else {
552 error!(
553 "[get_parent_from_full_path()]: Something went wrong when getting the parent directory"
554 );
555 Err("Something went wrong when getting the parent directory".into())
556 }
557}
558
559/// Verifies whether a diary file exists at the specified path.
560///
561/// This function checks if the file at the given path exists on the filesystem.
562/// It's used to determine whether to create a new diary file with a template
563/// or append to an existing one.
564///
565/// # Arguments
566///
567/// * `full_diary_path` - Path to the diary file to check
568///
569/// # Returns
570///
571/// - `Ok(())` - File exists at the specified path
572/// - `Err(Box<dyn Error>)` - File does not exist at the specified path
573///
574/// # Errors
575///
576/// Returns an error if:
577/// - The file does not exist on the filesystem
578/// - The path cannot be accessed due to permission issues
579/// - The path represents a directory instead of a file
580///
581/// # Examples
582///
583/// ```ignore
584/// use rusty_commit_saver::vim_commit::check_diary_path_exists;
585/// use std::path::PathBuf;
586/// use std::fs::File;
587///
588/// // Create a temporary test file
589/// let test_file = PathBuf::from("/tmp/test_diary.md");
590/// File::create(&test_file).unwrap();
591///
592/// // File exists - returns Ok
593/// assert!(check_diary_path_exists(&test_file).is_ok());
594///
595/// // File doesn't exist - returns Err
596/// let missing_file = PathBuf::from("/tmp/nonexistent.md");
597/// assert!(check_diary_path_exists(&missing_file).is_err());
598/// ```
599pub fn check_diary_path_exists(full_diary_path: &PathBuf) -> Result<(), Box<dyn Error>> {
600 info!(
601 "[check_diary_path_exists()]: Checking that full_diary_path exists: {:}",
602 full_diary_path.display()
603 );
604 if Path::new(&full_diary_path).exists() {
605 return Ok(());
606 }
607 warn!("[check_diary_path_exists()]: Path does not exist!");
608 Err("Path does not exist!".into())
609}
610
611/// Creates all necessary parent directories for a diary file path.
612///
613/// Recursively creates the complete directory hierarchy needed to store a diary file.
614/// Uses `fs::create_dir_all()` which is idempotent—calling it on existing directories
615/// is safe and will not cause errors.
616///
617/// # Arguments
618///
619/// * `obsidian_root_path_dir` - The full path including the filename for the diary entry
620///
621/// # Returns
622///
623/// - `Ok(())` - All parent directories were successfully created
624/// - `Err(Box<dyn Error>)` - Directory creation failed (permission denied, invalid path, etc.)
625///
626/// # Errors
627///
628/// Returns an error if:
629/// - The parent path cannot be determined (root directory)
630/// - No write permissions to the parent directory
631/// - Invalid filesystem (e.g., read-only filesystem)
632/// - Path components are invalid (e.g., null bytes)
633///
634/// # Examples
635///
636/// ```ignore
637/// use rusty_commit_saver::vim_commit::create_directories_for_new_entry;
638/// use std::path::PathBuf;
639/// use std::fs;
640///
641/// let diary_path = PathBuf::from("/tmp/test/deep/nested/path/diary.md");
642///
643/// // Create all parent directories
644/// create_directories_for_new_entry(&diary_path).unwrap();
645///
646/// // Verify the directories were created
647/// assert!(PathBuf::from("/tmp/test/deep/nested/path").exists());
648///
649/// // Calling again on existing directories is safe (idempotent)
650/// assert!(create_directories_for_new_entry(&diary_path).is_ok());
651/// ```
652pub fn create_directories_for_new_entry(
653 obsidian_root_path_dir: &Path,
654) -> Result<(), Box<dyn Error>> {
655 info!("[create_directories_for_new_entry()] Getting parent_dirs.");
656 let parent_dirs = get_parent_from_full_path(obsidian_root_path_dir)?;
657 fs::create_dir_all(parent_dirs)?;
658 info!("[create_directories_for_new_entry()] Creating diary file & path");
659
660 Ok(())
661}
662
663/// Creates a new diary file with Obsidian frontmatter and table template.
664///
665/// Generates a diary entry file with:
666/// - YAML frontmatter containing metadata and tags for Obsidian organization
667/// - A markdown table header for commit entries (folder, time, message, repo, branch, hash)
668/// - Pre-formatted for use with [`CommitSaver::append_entry_to_diary()`]
669///
670/// # Template Structure
671///
672/// The generated file uses the internal `DiaryFileEntry` markup template:
673///
674/// ```text
675/// ---
676/// category: diary
677/// section: commits
678/// tags:
679/// - '#datetime/week/02'
680/// - '#datetime/days/Monday'
681/// - '#diary/commits'
682/// date: 2025-01-14
683/// ---
684///
685/// # 2025-01-14
686///
687/// | FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
688/// |--------|------|----------------|----------------|--------|-------------|
689/// ```
690///
691/// # Arguments
692/// ... (rest of your existing documentation)
693///
694/// The created file is ready for commit entries to be appended to its table.
695///
696/// # Arguments
697///
698/// * `full_diary_file_path` - The complete path where the file should be created
699/// * `commit_saver_struct` - The `CommitSaver` instance to extract metadata from
700///
701/// # Returns
702///
703/// - `Ok(())` - File was successfully created with the template
704/// - `Err(Box<dyn Error>)` - File creation or write operation failed
705///
706/// # Errors
707///
708/// Returns an error if:
709/// - The file cannot be created (parent directory doesn't exist, permission denied)
710/// - Write operations fail (disk full, I/O error)
711/// - Path is invalid or contains invalid UTF-8
712///
713/// # Examples
714///
715/// ```ignore
716/// use rusty_commit_saver::vim_commit::create_diary_file;
717/// use rusty_commit_saver::CommitSaver;
718/// use chrono::{TimeZone, Utc};
719/// use std::fs;
720///
721/// let mut saver = CommitSaver {
722/// repository_url: "https://github.com/example/repo.git".to_string(),
723/// commit_branch_name: "main".to_string(),
724/// commit_hash: "abc123def456".to_string(),
725/// commit_msg: "feat: implement feature".to_string(),
726/// commit_datetime: Utc.with_ymd_and_hms(2025, 1, 14, 10, 30, 0).unwrap(),
727/// };
728///
729/// let file_path = "/home/user/diary/2025-01-14.md";
730/// create_diary_file(file_path, &mut saver).unwrap();
731///
732/// // Verify file was created with proper structure
733/// let content = fs::read_to_string(file_path).unwrap();
734/// assert!(content.contains("---")); // Frontmatter markers
735/// assert!(content.contains("category: diary"));
736/// assert!(content.contains("| FOLDER | TIME | COMMIT MESSAGE")); // Table header
737/// ```
738pub fn create_diary_file(
739 full_diary_file_path: &str,
740 commit_saver_struct: &mut CommitSaver,
741) -> Result<(), Box<dyn Error>> {
742 info!("[create_diary_file()]: Retrieving the frontmatter tags.");
743 let frontmatter = commit_saver_struct.prepare_frontmatter_tags();
744
745 info!("[create_diary_file()]: Retrieving the date for commit.");
746 let diary_date = commit_saver_struct
747 .commit_datetime
748 .format("%Y-%m-%d")
749 .to_string();
750
751 info!("[create_diary_file()]: Creating the DiaryFileEntry.");
752 let template = DiaryFileEntry {
753 frontmatter,
754 diary_date,
755 }
756 .to_string();
757
758 info!("[create_diary_file()]: Writing the DiaryFileEntry.");
759 fs::write(full_diary_file_path, template)?;
760
761 Ok(())
762}
763
764// CommitSaver tests
765#[cfg(test)]
766mod commit_saver_tests {
767 use super::*;
768 use chrono::{TimeZone, Utc};
769 use std::fs;
770 use std::fs::File;
771 use std::path::PathBuf;
772 use tempfile::tempdir;
773
774 fn create_test_commit_saver() -> CommitSaver {
775 CommitSaver {
776 repository_url: "https://github.com/test/repo.git".to_string(),
777 commit_branch_name: "main".to_string(),
778 commit_hash: "abc123def456".to_string(),
779 commit_msg: "Test commit message".to_string(),
780 commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
781 }
782 }
783
784 #[test]
785 fn test_commit_saver_new() {
786 // This test requires being in a git repository
787 // We'll mock the behavior or skip if not in a git repo
788 if Repository::discover("./").is_ok() {
789 let commit_saver = CommitSaver::new();
790
791 assert!(!commit_saver.repository_url.is_empty());
792 assert!(!commit_saver.commit_branch_name.is_empty());
793 assert!(!commit_saver.commit_hash.is_empty());
794 }
795 }
796
797 #[test]
798 fn test_prepare_commit_entry_as_string() {
799 let mut commit_saver = create_test_commit_saver();
800 let test_path = PathBuf::from("/test/path");
801
802 let result = commit_saver.prepare_commit_entry_as_string(&test_path);
803
804 assert!(result.contains("/test/path"));
805 assert!(result.contains("10:30:00"));
806 assert!(result.contains("Test commit message"));
807 assert!(result.contains("https://github.com/test/repo.git"));
808 assert!(result.contains("main"));
809 assert!(result.contains("abc123def456"));
810 assert!(result.ends_with("|\n"));
811 }
812
813 #[test]
814 fn test_prepare_commit_entry_with_pipe_escaping() {
815 let mut commit_saver = CommitSaver {
816 repository_url: "https://github.com/test/repo.git".to_string(),
817 commit_branch_name: "main".to_string(),
818 commit_hash: "abc123def456".to_string(),
819 commit_msg: "Test | commit | with | pipes".to_string(),
820 commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
821 };
822 let test_path = PathBuf::from("/test/path");
823
824 let result = commit_saver.prepare_commit_entry_as_string(&test_path);
825
826 // The commit message should have pipes escaped
827 assert!(result.contains("Test | commit | with | pipes"));
828 }
829
830 #[test]
831 fn test_prepare_frontmatter_tags() {
832 let mut commit_saver = create_test_commit_saver();
833
834 let tags = commit_saver.prepare_frontmatter_tags();
835
836 assert_eq!(tags.len(), 3);
837 assert!(tags.contains(&"#datetime/days/Monday".to_string()));
838 assert!(tags.contains(&"#diary/commits".to_string()));
839 }
840
841 #[test]
842 fn test_append_entry_to_diary() -> Result<(), Box<dyn std::error::Error>> {
843 let mut commit_saver = create_test_commit_saver();
844 let temp_dir = tempdir()?;
845 let file_path = temp_dir.path().join("test_diary.md");
846
847 // Create the file first
848 File::create(&file_path)?;
849
850 let result = commit_saver.append_entry_to_diary(&file_path);
851
852 assert!(result.is_ok());
853
854 // Verify content was written
855 let content = fs::read_to_string(&file_path)?;
856 assert!(content.contains("Test commit message"));
857 assert!(content.contains("abc123def456"));
858
859 Ok(())
860 }
861
862 #[test]
863 fn test_append_entry_to_diary_file_not_exists() {
864 let mut commit_saver = create_test_commit_saver();
865 let non_existent_path = PathBuf::from("/non/existent/file.md");
866
867 let result = commit_saver.append_entry_to_diary(&non_existent_path);
868
869 assert!(result.is_err());
870 }
871
872 #[test]
873 fn test_prepare_path_for_commit_integration() {
874 let mut commit_saver = create_test_commit_saver();
875 let obsidian_path = PathBuf::from("TestDiaries/Commits");
876 let date_template = "%Y/%m-%B/%F.md";
877
878 let result = commit_saver.prepare_path_for_commit(&obsidian_path, date_template);
879
880 // Should contain the formatted path
881 assert!(result.contains("/TestDiaries/Commits/"));
882 assert!(result.contains("2023"));
883 assert!(result.contains("12-December"));
884 // assert!(result.ends_with(".md"));
885 assert!(
886 std::path::Path::new(&result)
887 .extension()
888 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
889 );
890 }
891
892 #[test]
893 fn test_create_diary_file_error_handling() {
894 let mut commit_saver = create_test_commit_saver();
895
896 // Try to create file in a path that will fail (read-only location)
897 let result = create_diary_file("/proc/invalid_path/file.md", &mut commit_saver);
898
899 // Should return an error
900 assert!(result.is_err());
901 }
902
903 #[test]
904 fn test_get_parent_from_full_path_edge_cases() {
905 use std::path::Path;
906
907 // Test with a simple path
908 let path = Path::new("/home/user/file.txt");
909 let parent = get_parent_from_full_path(path);
910 assert!(parent.is_ok());
911 assert_eq!(parent.unwrap(), Path::new("/home/user"));
912
913 // Test with nested path
914 let nested = Path::new("/a/b/c/d/e/file.txt");
915 let nested_parent = get_parent_from_full_path(nested);
916 assert!(nested_parent.is_ok());
917 }
918
919 #[test]
920 fn test_commit_saver_default_in_git_repo() {
921 use git2::Repository;
922
923 // Only run if we're in a git repo
924 if Repository::discover("./").is_ok() {
925 let commit_saver = CommitSaver::default();
926
927 // Verify all fields are populated
928 assert!(!commit_saver.repository_url.is_empty());
929 assert!(!commit_saver.commit_branch_name.is_empty());
930 assert!(!commit_saver.commit_hash.is_empty());
931 assert!(!commit_saver.commit_msg.is_empty());
932
933 // Hash should be 40 characters (SHA-1)
934 assert_eq!(commit_saver.commit_hash.len(), 40);
935 }
936 }
937
938 #[test]
939 fn test_prepare_path_for_commit_with_empty_template() {
940 let mut commit_saver = create_test_commit_saver();
941 let obsidian_path = PathBuf::from("Diaries");
942 let empty_template = "";
943
944 let result = commit_saver.prepare_path_for_commit(&obsidian_path, empty_template);
945
946 // Should still produce a path even with empty template
947 assert!(result.contains("Diaries"));
948 }
949
950 #[test]
951 fn test_commit_msg_with_only_whitespace_lines() {
952 let commit_saver = CommitSaver {
953 repository_url: "test".to_string(),
954 commit_branch_name: "main".to_string(),
955 commit_hash: "abc123".to_string(),
956 commit_msg: " \n\n \n".to_string(), // Only whitespace
957 commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
958 };
959
960 // commit_msg should be empty or minimal after filtering
961 assert!(commit_saver.commit_msg.is_empty() || commit_saver.commit_msg.len() < 10);
962 }
963
964 #[test]
965 fn test_create_diary_file_frontmatter_formatting() -> Result<(), Box<dyn std::error::Error>> {
966 let temp_dir = tempdir()?;
967 let file_path = temp_dir.path().join("diary.md");
968 let mut commit_saver = create_test_commit_saver();
969
970 create_diary_file(file_path.to_str().unwrap(), &mut commit_saver)?;
971
972 let content = fs::read_to_string(&file_path)?;
973
974 // Verify frontmatter structure
975 assert!(content.starts_with("---"));
976 assert!(content.contains("category: diary"));
977 assert!(content.contains("section: commits"));
978 assert!(content.contains("tags:"));
979 assert!(content.contains("#diary/commits"));
980
981 Ok(())
982 }
983
984 #[test]
985 fn test_diary_file_entry_markup_generation() {
986 let frontmatter = vec![
987 "#datetime/week/52".to_string(),
988 "#datetime/days/Saturday".to_string(),
989 "#diary/commits".to_string(),
990 ];
991 let diary_date = "2023-12-30".to_string();
992
993 let markup = DiaryFileEntry {
994 frontmatter,
995 diary_date,
996 };
997
998 let output = markup.to_string();
999
1000 // Verify markup structure
1001 assert!(output.contains("---"));
1002 assert!(output.contains("category: diary"));
1003 assert!(output.contains("#datetime/week/52"));
1004 assert!(output.contains("#datetime/days/Saturday"));
1005 assert!(output.contains("2023-12-30"));
1006 assert!(output.contains("| FOLDER | TIME | COMMIT MESSAGE"));
1007 }
1008}