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