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    /// Builds a `CommitSaver` from the Git repository discovered in the current
143    /// directory.
144    ///
145    /// # Panics
146    ///
147    /// Panics if the current directory is not inside a Git repository, the
148    /// repository has no resolvable `HEAD`, or `HEAD` cannot be peeled to a
149    /// commit. Use [`CommitSaver::try_new`] for a non-panicking variant.
150    fn default() -> CommitSaver {
151        CommitSaver::try_new().expect("failed to build CommitSaver from the current Git repository")
152    }
153}
154
155impl CommitSaver {
156    /// Builds a `CommitSaver` from an explicit repository handle.
157    ///
158    /// This is the fallible core that [`CommitSaver::try_new`], [`CommitSaver::new`]
159    /// and [`CommitSaver::default`] all delegate to. Taking the repository as a
160    /// parameter — rather than discovering the ambient working directory — keeps
161    /// the metadata extraction pure and testable, with no dependency on
162    /// process-global state such as the current directory.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the repository has no resolvable `HEAD`, if `HEAD`
167    /// cannot be peeled to a commit, or if the commit timestamp is out of the
168    /// representable range.
169    pub fn from_repo(git_repo: &Repository) -> Result<Self, Box<dyn Error>> {
170        let head = git_repo.head()?;
171        let commit = head.peel_to_commit()?;
172        let commit_datetime = DateTime::from_timestamp(commit.time().seconds(), 0)
173            .ok_or("commit timestamp is out of range")?;
174
175        Ok(CommitSaver {
176            repository_url: match git_repo.find_remote("origin") {
177                Ok(remote) => remote.url().unwrap_or("no_url_set").replace('"', ""),
178                _ => "no_url_set".to_string(),
179            },
180            commit_branch_name: head.shorthand().unwrap_or("no_branch_set").replace('"', ""),
181            commit_hash: commit.id().to_string(),
182            // Preserve original lines, escape pipes, then join with <br/>
183            commit_msg: commit
184                .message()
185                .unwrap_or("")
186                .lines()
187                .map(|line| line.trim().replace('|', "\\|"))
188                .filter(|line| !line.is_empty())
189                .collect::<Vec<_>>()
190                .join("<br/>"),
191            commit_datetime,
192        })
193    }
194
195    /// Internal helper for path-injected repository discovery.
196    ///
197    /// Discovers a Git repository at the given path and builds a `CommitSaver`
198    /// from its `HEAD` commit. This function enables testing of error cases
199    /// without mutating the process's current directory.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if no Git repository can be discovered from the given
204    /// path, or if [`CommitSaver::from_repo`] fails for the discovered repo.
205    fn try_discover(path: &Path) -> Result<Self, Box<dyn Error>> {
206        let git_repo = Repository::discover(path)?;
207        CommitSaver::from_repo(&git_repo)
208    }
209
210    /// Discovers the Git repository in the current directory and builds a
211    /// `CommitSaver` from its `HEAD` commit.
212    ///
213    /// This is the non-panicking counterpart to [`CommitSaver::new`] /
214    /// [`CommitSaver::default`].
215    ///
216    /// # Errors
217    ///
218    /// Returns an error if no Git repository can be discovered from the current
219    /// directory, or if [`CommitSaver::from_repo`] fails for the discovered repo.
220    pub fn try_new() -> Result<Self, Box<dyn Error>> {
221        CommitSaver::try_discover(Path::new("./"))
222    }
223
224    /// Creates a new `CommitSaver` instance by discovering the current Git repository.
225    ///
226    /// This function automatically:
227    /// - Discovers the Git repository in the current directory (`.`)
228    /// - Extracts commit metadata from the HEAD commit
229    /// - Formats the commit message for Obsidian (escapes pipes, adds `<br/>`)
230    ///
231    /// # Panics
232    ///
233    /// Panics if:
234    /// - No Git repository is found in the current directory
235    /// - The repository has no HEAD (uninitialized repo)
236    /// - The HEAD cannot be resolved to a commit
237    ///
238    /// # Examples
239    ///
240    /// ```ignore
241    /// use rusty_commit_saver::CommitSaver;
242    ///
243    /// let saver = CommitSaver::new();
244    /// println!("Commit hash: {}", saver.commit_hash);
245    /// ```
246    #[must_use]
247    pub fn new() -> Self {
248        CommitSaver::default()
249    }
250
251    /// Formats commit metadata as a Markdown table row for diary entry.
252    ///
253    /// Generates a single table row containing all commit information in the format
254    /// expected by the Obsidian diary template. The row includes pipe delimiters
255    /// and ends with a newline.
256    ///
257    /// # Arguments
258    ///
259    /// * `path` - The current working directory where the commit was made
260    ///
261    /// # Returns
262    ///
263    /// A formatted string representing one table row with these columns:
264    /// 1. **FOLDER** - Current working directory path
265    /// 2. **TIME** - Commit timestamp (HH:MM:SS format)
266    /// 3. **COMMIT MESSAGE** - Escaped and formatted commit message
267    /// 4. **REPOSITORY URL** - Git remote origin URL
268    /// 5. **BRANCH** - Current branch name
269    /// 6. **COMMIT HASH** - Full SHA-1 commit hash
270    ///
271    /// # Format
272    ///
273    /// ```text
274    /// | /path/to/repo | 14:30:45 | feat: add feature | https://github.com/user/repo.git | main | abc123... |
275    /// ```
276    ///
277    /// # Note
278    ///
279    /// This is a private helper method called by [`append_entry_to_diary()`](Self::append_entry_to_diary).
280    /// The commit message has already been formatted with escaped pipes and `<br/>` separators
281    /// during struct initialization.
282    fn prepare_commit_entry_as_string(&mut self, path: &Path, time_format: &str) -> String {
283        format!(
284            "| {:} | {:} | {:} | {:} | {:} | {:} |\n",
285            path.display(),
286            self.commit_datetime.format(time_format),
287            self.commit_msg,
288            self.repository_url,
289            self.commit_branch_name,
290            self.commit_hash
291        )
292    }
293
294    /// Generates Obsidian-style frontmatter tags based on the commit timestamp.
295    ///
296    /// Creates three metadata tags for organizing diary entries:
297    /// 1. **Week tag**: `#datetime/week/WW` (e.g., `#datetime/week/02` for week 2)
298    /// 2. **Day tag**: `#datetime/days/DDDD` (e.g., `#datetime/days/Monday`)
299    /// 3. **Category tag**: `#diary/commits` (constant)
300    ///
301    /// These tags are used in the Obsidian diary file's YAML frontmatter to enable:
302    /// - Filtering commits by week number
303    /// - Organizing by day of week
304    /// - Cross-referencing with other diary entries
305    ///
306    /// # Returns
307    ///
308    /// A vector of three strings containing formatted Obsidian tags
309    ///
310    /// # Examples
311    ///
312    /// ```ignore
313    /// use rusty_commit_saver::CommitSaver;
314    /// use chrono::{TimeZone, Utc};
315    ///
316    /// let mut saver = CommitSaver {
317    ///     repository_url: "https://github.com/example/repo.git".to_string(),
318    ///     commit_branch_name: "main".to_string(),
319    ///     commit_hash: "abc123".to_string(),
320    ///     commit_msg: "feat: add feature".to_string(),
321    ///     commit_datetime: Utc.with_ymd_and_hms(2025, 1, 13, 10, 30, 0).unwrap(), // Monday
322    /// };
323    ///
324    /// let tags = saver.prepare_frontmatter_tags();
325    /// assert_eq!(tags.len(), 3);
326    /// assert!(tags.contains("week"));
327    /// assert!(tags.contains("Monday"));[1]
328    /// assert_eq!(tags, "#diary/commits");
329    /// ```
330    pub fn prepare_frontmatter_tags(&mut self) -> Vec<String> {
331        info!("[CommitSaver::prepare_frontmatter_tags()]: Preparing the frontmatter week number.");
332        let week_number = format!("#datetime/week/{:}", self.commit_datetime.format("%W"));
333
334        info!("[CommitSaver::prepare_frontmatter_tags()]: Preparing the frontmatter week day.");
335        let week_day = format!("#datetime/days/{:}", self.commit_datetime.format("%A"));
336
337        info!(
338            "[CommitSaver::prepare_frontmatter_tags()]: Returing the formatted vector with the frontmatter tags week number and day."
339        );
340        vec![week_number, week_day, "#diary/commits".to_string()]
341    }
342
343    /// Constructs the full file path for a diary entry based on the commit timestamp.
344    ///
345    /// Combines the Obsidian commit directory path with a date-formatted subdirectory structure
346    /// to create the final path where the commit entry should be saved.
347    ///
348    /// # Arguments
349    ///
350    /// * `obsidian_commit_path` - Base directory path for commits (e.g., `Diaries/Commits`)
351    /// * `template_commit_date_path` - Chrono format string for the date hierarchy (e.g., `%Y/%m-%B/%F.md`)
352    ///
353    /// # Returns
354    ///
355    /// A formatted path string combining the base directory and formatted date
356    ///
357    /// # Format Specifiers (Chrono)
358    ///
359    /// - `%Y` - Year (e.g., `2025`)
360    /// - `%m` - Month as number (e.g., `01`)
361    /// - `%B` - Full month name (e.g., `January`)
362    /// - `%F` - ISO 8601 date (e.g., `2025-01-14.md`)
363    /// - `%d` - Day of month (e.g., `14`)
364    ///
365    /// # Panics
366    ///
367    /// Panics if:
368    /// - The `obsidian_commit_path` cannot be converted to a valid UTF-8 string
369    /// - The path contains invalid characters that cannot be represented as a string
370    ///
371    /// # Examples
372    ///
373    /// ```ignore
374    /// use rusty_commit_saver::CommitSaver;
375    /// use std::path::PathBuf;
376    /// use chrono::{TimeZone, Utc};
377    ///
378    /// let mut saver = CommitSaver {
379    ///     repository_url: "https://github.com/example/repo.git".to_string(),
380    ///     commit_branch_name: "main".to_string(),
381    ///     commit_hash: "abc123".to_string(),
382    ///     commit_msg: "feat: add feature".to_string(),
383    ///     commit_datetime: Utc.with_ymd_and_hms(2025, 1, 14, 10, 30, 0).unwrap(),
384    /// };
385    ///
386    /// let path = saver.prepare_path_for_commit(
387    ///     &PathBuf::from("Diaries/Commits"),
388    ///     "%Y/%m-%B/%F.md"
389    /// );
390    /// // Returns: "/Diaries/Commits/2025/01-January/2025-01-14.md"
391    /// assert!(path.contains("2025"));
392    /// assert!(path.contains("January"));
393    /// assert!(path.contains("2025-01-14.md"));
394    /// ```
395    pub fn prepare_path_for_commit(
396        &mut self,
397        obsidian_commit_path: &Path,
398        template_commit_date_path: &str,
399    ) -> String {
400        info!("[CommitSaver::prepare_path_for_commit()]: Preparing the path for commit file.");
401        let commit_path = obsidian_commit_path
402            .as_os_str()
403            .to_str()
404            .expect("asd")
405            .to_string();
406
407        info!("[CommitSaver::prepare_path_for_commit()]: Retrieving the path for commit file.");
408        let paths_with_dates_and_file =
409            self.prepare_date_for_commit_file(template_commit_date_path);
410
411        info!(
412            "[CommitSaver::prepare_path_for_commit()]: Returning the full String of the ComitPath and File."
413        );
414        format!("/{commit_path:}/{paths_with_dates_and_file:}")
415    }
416
417    /// Formats the commit timestamp using a Chrono date format string.
418    ///
419    /// Applies the given format template to the commit's datetime to generate
420    /// a date-based directory path or filename. This enables flexible organization
421    /// of diary entries by year, month, week, or custom hierarchies.
422    ///
423    /// # Arguments
424    ///
425    /// * `path_format` - Chrono format string (e.g., `%Y/%m-%B/%F.md`)
426    ///
427    /// # Returns
428    ///
429    /// A formatted date string suitable for file paths
430    ///
431    /// # Common Format Specifiers
432    ///
433    /// - `%Y` - Year (4 digits, e.g., `2025`)
434    /// - `%m` - Month (2 digits, e.g., `01`)
435    /// - `%B` - Full month name (e.g., `January`)
436    /// - `%b` - Abbreviated month (e.g., `Jan`)
437    /// - `%d` - Day of month (2 digits, e.g., `14`)
438    /// - `%F` - ISO 8601 date format (`%Y-%m-%d`, e.g., `2025-01-14`)
439    /// - `%A` - Full weekday name (e.g., `Monday`)
440    /// - `%W` - Week number (e.g., `02`)
441    ///
442    /// # Examples
443    ///
444    /// ```text
445    /// // With format "%Y/%m-%B/%F.md" and datetime 2025-01-14:
446    /// // Returns: "2025/01-January/2025-01-14.md"
447    ///
448    /// // With format "%Y/week-%W/%F.md" and datetime in week 2:
449    /// // Returns: "2025/week-02/2025-01-14.md"
450    /// ```
451    ///
452    /// # Note
453    ///
454    /// This is a private helper method called by [`prepare_path_for_commit()`](Self::prepare_path_for_commit).
455    fn prepare_date_for_commit_file(&mut self, path_format: &str) -> String {
456        info!(
457            "[CommitSaver::prepare_date_for_commit_file()]: Formatting commit path with DateTime."
458        );
459        // %B	July	Full month name. Also accepts corresponding abbreviation in parsing.
460        // %F	2001-07-08	Year-month-day format (ISO 8601). Same as %Y-%m-%d.
461        self.commit_datetime.format(path_format).to_string()
462    }
463
464    /// Appends the current commit as a table row to an Obsidian diary file.
465    ///
466    /// This method writes a formatted commit entry to the specified diary file in append mode.
467    /// The entry includes: current directory, timestamp, commit message, repository URL, branch, and commit hash.
468    ///
469    /// # Arguments
470    ///
471    /// * `wiki` - Path to the diary file where the commit entry should be appended
472    ///
473    /// # Returns
474    ///
475    /// - `Ok(())` - Successfully appended the commit entry to the file
476    /// - `Err(Box<dyn Error>)` - If file operations fail (file doesn't exist, permission denied, etc.)
477    ///
478    /// # Errors
479    ///
480    /// Returns an error if:
481    /// - The diary file cannot be opened for appending
482    /// - The current working directory cannot be determined
483    /// - File write operations fail (I/O error, permission denied)
484    ///
485    /// # Examples
486    ///
487    /// ```ignore
488    /// use rusty_commit_saver::CommitSaver;
489    /// use std::path::PathBuf;
490    ///
491    /// let mut saver = CommitSaver::new();
492    /// let diary_path = PathBuf::from("/home/user/diary/2025-01-14.md");
493    ///
494    /// match saver.append_entry_to_diary(&diary_path) {
495    ///     Ok(()) => println!("Commit logged successfully!"),
496    ///     Err(e) => eprintln!("Failed to log commit: {}", e),
497    /// }
498    /// ```
499    pub fn append_entry_to_diary(
500        &mut self,
501        wiki: &PathBuf,
502        time_format: &str,
503    ) -> Result<(), Box<dyn Error>> {
504        info!("[CommitSaver::append_entry_to_diary()]: Getting current directory.");
505        let path = env::current_dir()?;
506
507        info!("[CommitSaver::append_entry_to_diary()]: Preparing the commit_entry_as_string.");
508        let new_commit_str = self.prepare_commit_entry_as_string(&path, time_format);
509
510        debug!("[CommitSaver::append_entry_to_diary()]: Commit String: {new_commit_str:}");
511        debug!(
512            "[CommitSaver::append_entry_to_diary()]: Wiki:\n{:}",
513            wiki.display()
514        );
515        let mut file_ref = OpenOptions::new().append(true).open(wiki)?;
516
517        file_ref.write_all(new_commit_str.as_bytes())?;
518
519        Ok(())
520    }
521}
522
523// Markup template for generating Obsidian diary file structure.
524//
525// This macro defines the template for new diary entry files, including:
526// - YAML frontmatter with metadata and tags
527// - Main heading with the date
528// - Markdown table header for commit entries
529//
530// Used internally by create_diary_file().
531markup::define! {
532    DiaryFileEntry(frontmatter: Vec<String>, diary_date: String) {
533"---
534category: diary\n
535section: commits\n
536tags:\n"
537@for tag in frontmatter.iter() {
538"- '" @tag "'\n"
539}
540"date: " @diary_date
541"\n
542---
543\n
544# " @diary_date
545"\n
546| FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
547|--------|------|----------------|----------------|--------|-------------|\n"
548    }
549}
550
551/// Returns the working-directory name of a Git repository.
552///
553/// This is the basename of the repository's work tree (e.g. `claude-src` for a
554/// repo checked out at `/home/user/src/claude-src`). It is the stable identity
555/// used for the exclude list, independent of which subdirectory a commit is made
556/// from.
557///
558/// # Returns
559///
560/// - `Some(name)` - The repository's working-directory basename
561/// - `None` - The repository is bare (no work tree) or the path has no basename
562#[must_use]
563pub fn repo_workdir_name(repo: &Repository) -> Option<String> {
564    repo.workdir()
565        .and_then(Path::file_name)
566        .map(|name| name.to_string_lossy().into_owned())
567}
568
569/// Returns the working-directory name of the Git repository at the current path.
570///
571/// Discovers the repository from the current directory (`./`) and returns its
572/// working-directory basename via [`repo_workdir_name`]. Returns `None` when no
573/// repository can be discovered.
574#[must_use]
575pub fn current_repo_workdir_name() -> Option<String> {
576    let repo = Repository::discover("./").ok()?;
577    repo_workdir_name(&repo)
578}
579
580/// Extracts the repository name from a Git remote URL.
581///
582/// Handles the common remote forms — scp-style (`git@host:org/repo.git`),
583/// https (`https://host/org/repo.git`), ssh (`ssh://git@host/org/repo.git`),
584/// and bare local paths — by dropping a trailing `.git` and taking the final
585/// path segment (splitting on both `/` and `:`). Returns `None` for an empty
586/// input or the `no_url_set` sentinel that [`CommitSaver::from_repo`] uses when
587/// no `origin` remote exists.
588#[must_use]
589pub fn repo_name_from_url(url: &str) -> Option<String> {
590    let url = url.trim();
591    if url.is_empty() || url == "no_url_set" {
592        return None;
593    }
594    let stem = url.trim_end_matches('/');
595    let stem = stem.strip_suffix(".git").unwrap_or(stem);
596    stem.rsplit(['/', ':'])
597        .next()
598        .filter(|segment| !segment.is_empty())
599        .map(str::to_owned)
600}
601
602/// Returns the repository's canonical identity for exclusion matching.
603///
604/// The name is taken from the `origin` remote URL (see [`repo_name_from_url`]),
605/// which is stable across every worktree of the same repository — so a single
606/// exclude entry covers a repo no matter what its worktree directories are
607/// named. Falls back to the working-directory basename ([`repo_workdir_name`])
608/// when there is no usable `origin` remote (e.g. a local-only repository).
609#[must_use]
610pub fn canonical_repo_name(repo: &Repository) -> Option<String> {
611    if let Ok(remote) = repo.find_remote("origin") {
612        if let Ok(url) = remote.url() {
613            if let Some(name) = repo_name_from_url(url) {
614                return Some(name);
615            }
616        }
617    }
618    repo_workdir_name(repo)
619}
620
621/// Returns the canonical name of the repository discovered from the current path.
622///
623/// Like [`current_repo_workdir_name`], but resolves the repository's canonical
624/// identity via [`canonical_repo_name`] (its `origin` remote name) rather than
625/// the ambient worktree basename. Returns `None` when no repository can be
626/// discovered.
627#[must_use]
628pub fn current_repo_canonical_name() -> Option<String> {
629    let repo = Repository::discover("./").ok()?;
630    canonical_repo_name(&repo)
631}
632
633/// Reports whether a repository name is present in the exclude list.
634///
635/// Matching is an exact, case-sensitive comparison of the repository's
636/// canonical name against each configured entry.
637///
638/// # Arguments
639///
640/// * `repo_name` - The repository's canonical name (see [`canonical_repo_name`])
641/// * `exclude_list` - The configured repository names to skip
642///
643/// # Examples
644///
645/// ```ignore
646/// use rusty_commit_saver::vim_commit::is_repo_excluded;
647///
648/// let list = vec!["claude-src".to_string()];
649/// assert!(is_repo_excluded("claude-src", &list));
650/// assert!(!is_repo_excluded("other-repo", &list));
651/// ```
652#[must_use]
653pub fn is_repo_excluded(repo_name: &str, exclude_list: &[String]) -> bool {
654    exclude_list.iter().any(|excluded| excluded == repo_name)
655}
656
657/// Extracts the parent directory from a file path.
658///
659/// Returns a reference to the parent directory component of the given path.
660/// This is useful for creating parent directories before writing a file.
661///
662/// # Arguments
663///
664/// * `full_diary_path` - A file path to extract the parent directory from
665///
666/// # Returns
667///
668/// - `Ok(&Path)` - Reference to the parent directory
669/// - `Err(Box<dyn Error>)` - If the path has no parent (e.g., root directory `/`)
670///
671/// # Errors
672///
673/// Returns an error if:
674/// - The path is the root directory (has no parent)
675/// - The path is a relative single component with no parent
676///
677/// # Examples
678///
679/// ```ignore
680/// use rusty_commit_saver::vim_commit::get_parent_from_full_path;
681/// use std::path::{Path, PathBuf};
682///
683/// // Normal nested path
684/// let path = Path::new("/home/user/documents/diary.md");
685/// let parent = get_parent_from_full_path(path).unwrap();
686/// assert_eq!(parent, Path::new("/home/user/documents"));
687///
688/// // Deep nesting
689/// let deep = Path::new("/a/b/c/d/e/f/file.txt");
690/// let parent = get_parent_from_full_path(deep).unwrap();
691/// assert_eq!(parent, Path::new("/a/b/c/d/e/f"));
692///
693/// // Root directory fails
694/// let root = Path::new("/");
695/// assert!(get_parent_from_full_path(root).is_err());
696/// ```
697pub fn get_parent_from_full_path(full_diary_path: &Path) -> Result<&Path, Box<dyn Error>> {
698    info!(
699        "[get_parent_from_full_path()] Checking if there is parents for: {:}.",
700        full_diary_path.display()
701    );
702    if let Some(dir) = full_diary_path.parent() {
703        Ok(dir)
704    } else {
705        error!(
706            "[get_parent_from_full_path()]: Something went wrong when getting the parent directory"
707        );
708        Err("Something went wrong when getting the parent directory".into())
709    }
710}
711
712/// Verifies whether a diary file exists at the specified path.
713///
714/// This function checks if the file at the given path exists on the filesystem.
715/// It's used to determine whether to create a new diary file with a template
716/// or append to an existing one.
717///
718/// # Arguments
719///
720/// * `full_diary_path` - Path to the diary file to check
721///
722/// # Returns
723///
724/// - `Ok(())` - File exists at the specified path
725/// - `Err(Box<dyn Error>)` - File does not exist at the specified path
726///
727/// # Errors
728///
729/// Returns an error if:
730/// - The file does not exist on the filesystem
731/// - The path cannot be accessed due to permission issues
732/// - The path represents a directory instead of a file
733///
734/// # Examples
735///
736/// ```ignore
737/// use rusty_commit_saver::vim_commit::check_diary_path_exists;
738/// use std::path::PathBuf;
739/// use std::fs::File;
740///
741/// // Create a temporary test file
742/// let test_file = PathBuf::from("/tmp/test_diary.md");
743/// File::create(&test_file).unwrap();
744///
745/// // File exists - returns Ok
746/// assert!(check_diary_path_exists(&test_file).is_ok());
747///
748/// // File doesn't exist - returns Err
749/// let missing_file = PathBuf::from("/tmp/nonexistent.md");
750/// assert!(check_diary_path_exists(&missing_file).is_err());
751/// ```
752pub fn check_diary_path_exists(full_diary_path: &PathBuf) -> Result<(), Box<dyn Error>> {
753    info!(
754        "[check_diary_path_exists()]: Checking that full_diary_path exists: {:}",
755        full_diary_path.display()
756    );
757    if Path::new(&full_diary_path).exists() {
758        return Ok(());
759    }
760    warn!("[check_diary_path_exists()]: Path does not exist!");
761    Err("Path does not exist!".into())
762}
763
764/// Creates all necessary parent directories for a diary file path.
765///
766/// Recursively creates the complete directory hierarchy needed to store a diary file.
767/// Uses `fs::create_dir_all()` which is idempotent—calling it on existing directories
768/// is safe and will not cause errors.
769///
770/// # Arguments
771///
772/// * `obsidian_root_path_dir` - The full path including the filename for the diary entry
773///
774/// # Returns
775///
776/// - `Ok(())` - All parent directories were successfully created
777/// - `Err(Box<dyn Error>)` - Directory creation failed (permission denied, invalid path, etc.)
778///
779/// # Errors
780///
781/// Returns an error if:
782/// - The parent path cannot be determined (root directory)
783/// - No write permissions to the parent directory
784/// - Invalid filesystem (e.g., read-only filesystem)
785/// - Path components are invalid (e.g., null bytes)
786///
787/// # Examples
788///
789/// ```ignore
790/// use rusty_commit_saver::vim_commit::create_directories_for_new_entry;
791/// use std::path::PathBuf;
792/// use std::fs;
793///
794/// let diary_path = PathBuf::from("/tmp/test/deep/nested/path/diary.md");
795///
796/// // Create all parent directories
797/// create_directories_for_new_entry(&diary_path).unwrap();
798///
799/// // Verify the directories were created
800/// assert!(PathBuf::from("/tmp/test/deep/nested/path").exists());
801///
802/// // Calling again on existing directories is safe (idempotent)
803/// assert!(create_directories_for_new_entry(&diary_path).is_ok());
804/// ```
805pub fn create_directories_for_new_entry(
806    obsidian_root_path_dir: &Path,
807) -> Result<(), Box<dyn Error>> {
808    info!("[create_directories_for_new_entry()] Getting parent_dirs.");
809    let parent_dirs = get_parent_from_full_path(obsidian_root_path_dir)?;
810    fs::create_dir_all(parent_dirs)?;
811    info!("[create_directories_for_new_entry()] Creating diary file & path");
812
813    Ok(())
814}
815
816/// Creates a new diary file with Obsidian frontmatter and table template.
817///
818/// Generates a diary entry file with:
819/// - YAML frontmatter containing metadata and tags for Obsidian organization
820/// - A markdown table header for commit entries (folder, time, message, repo, branch, hash)
821/// - Pre-formatted for use with [`CommitSaver::append_entry_to_diary()`]
822///
823/// # Template Structure
824///
825/// The generated file uses the internal `DiaryFileEntry` markup template:
826///
827/// ```text
828/// ---
829/// category: diary
830/// section: commits
831/// tags:
832/// - '#datetime/week/02'
833/// - '#datetime/days/Monday'
834/// - '#diary/commits'
835/// date: 2025-01-14
836/// ---
837///
838/// # 2025-01-14
839///
840/// | FOLDER | TIME | COMMIT MESSAGE | REPOSITORY URL | BRANCH | COMMIT HASH |
841/// |--------|------|----------------|----------------|--------|-------------|
842/// ```
843///
844/// # Arguments
845/// ... (rest of your existing documentation)
846///
847/// The created file is ready for commit entries to be appended to its table.
848///
849/// # Arguments
850///
851/// * `full_diary_file_path` - The complete path where the file should be created
852/// * `commit_saver_struct` - The `CommitSaver` instance to extract metadata from
853///
854/// # Returns
855///
856/// - `Ok(())` - File was successfully created with the template
857/// - `Err(Box<dyn Error>)` - File creation or write operation failed
858///
859/// # Errors
860///
861/// Returns an error if:
862/// - The file cannot be created (parent directory doesn't exist, permission denied)
863/// - Write operations fail (disk full, I/O error)
864/// - Path is invalid or contains invalid UTF-8
865///
866/// # Examples
867///
868/// ```ignore
869/// use rusty_commit_saver::vim_commit::create_diary_file;
870/// use rusty_commit_saver::CommitSaver;
871/// use chrono::{TimeZone, Utc};
872/// use std::fs;
873///
874/// let mut saver = CommitSaver {
875///     repository_url: "https://github.com/example/repo.git".to_string(),
876///     commit_branch_name: "main".to_string(),
877///     commit_hash: "abc123def456".to_string(),
878///     commit_msg: "feat: implement feature".to_string(),
879///     commit_datetime: Utc.with_ymd_and_hms(2025, 1, 14, 10, 30, 0).unwrap(),
880/// };
881///
882/// let file_path = "/home/user/diary/2025-01-14.md";
883/// create_diary_file(file_path, &mut saver).unwrap();
884///
885/// // Verify file was created with proper structure
886/// let content = fs::read_to_string(file_path).unwrap();
887/// assert!(content.contains("---")); // Frontmatter markers
888/// assert!(content.contains("category: diary"));
889/// assert!(content.contains("| FOLDER | TIME | COMMIT MESSAGE")); // Table header
890/// ```
891pub fn create_diary_file(
892    full_diary_file_path: &str,
893    commit_saver_struct: &mut CommitSaver,
894) -> Result<(), Box<dyn Error>> {
895    info!("[create_diary_file()]: Retrieving the frontmatter tags.");
896    let frontmatter = commit_saver_struct.prepare_frontmatter_tags();
897
898    info!("[create_diary_file()]: Retrieving the date for commit.");
899    let diary_date = commit_saver_struct
900        .commit_datetime
901        .format("%Y-%m-%d")
902        .to_string();
903
904    info!("[create_diary_file()]: Creating the DiaryFileEntry.");
905    let template = DiaryFileEntry {
906        frontmatter,
907        diary_date,
908    }
909    .to_string();
910
911    info!("[create_diary_file()]: Writing the DiaryFileEntry.");
912    fs::write(full_diary_file_path, template)?;
913
914    Ok(())
915}
916
917// CommitSaver tests
918#[cfg(test)]
919#[cfg_attr(coverage_nightly, coverage(off))]
920mod commit_saver_tests {
921    use super::*;
922    use chrono::{TimeZone, Utc};
923    use std::fs;
924    use std::fs::File;
925    use std::path::PathBuf;
926    use tempfile::tempdir;
927
928    fn create_test_commit_saver() -> CommitSaver {
929        CommitSaver {
930            repository_url: "https://github.com/test/repo.git".to_string(),
931            commit_branch_name: "main".to_string(),
932            commit_hash: "abc123def456".to_string(),
933            commit_msg: "Test commit message".to_string(),
934            commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
935        }
936    }
937
938    #[test]
939    fn test_commit_saver_new() {
940        // This test requires being in a git repository
941        // We'll mock the behavior or skip if not in a git repo
942        if Repository::discover("./").is_ok() {
943            let commit_saver = CommitSaver::new();
944
945            assert!(!commit_saver.repository_url.is_empty());
946            assert!(!commit_saver.commit_branch_name.is_empty());
947            assert!(!commit_saver.commit_hash.is_empty());
948        }
949    }
950
951    #[test]
952    fn test_prepare_commit_entry_as_string() {
953        let mut commit_saver = create_test_commit_saver();
954        let test_path = PathBuf::from("/test/path");
955
956        let result = commit_saver.prepare_commit_entry_as_string(&test_path, "%H:%M:%S");
957
958        assert!(result.contains("/test/path"));
959        assert!(result.contains("10:30:00"));
960        assert!(result.contains("Test commit message"));
961        assert!(result.contains("https://github.com/test/repo.git"));
962        assert!(result.contains("main"));
963        assert!(result.contains("abc123def456"));
964        assert!(result.ends_with("|\n"));
965    }
966
967    #[test]
968    fn test_prepare_commit_entry_honours_the_configured_time_format() {
969        // `[templates] commit_datetime` was read from the config, required on
970        // pain of a fatal error, and then never consumed: the TIME column was
971        // hardcoded. The key now means what it says.
972        let mut commit_saver = create_test_commit_saver();
973        let test_path = PathBuf::from("/test/path");
974
975        let result = commit_saver.prepare_commit_entry_as_string(&test_path, "%H%Mh");
976
977        assert!(
978            result.contains("1030h"),
979            "the configured format must reach the row: {result}"
980        );
981        assert!(
982            !result.contains("10:30:00"),
983            "the hardcoded format must no longer win: {result}"
984        );
985    }
986
987    #[test]
988    fn test_prepare_commit_entry_with_pipe_escaping() {
989        let mut commit_saver = CommitSaver {
990            repository_url: "https://github.com/test/repo.git".to_string(),
991            commit_branch_name: "main".to_string(),
992            commit_hash: "abc123def456".to_string(),
993            commit_msg: "Test | commit | with | pipes".to_string(),
994            commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
995        };
996        let test_path = PathBuf::from("/test/path");
997
998        let result = commit_saver.prepare_commit_entry_as_string(&test_path, "%H:%M:%S");
999
1000        // The commit message should have pipes escaped
1001        assert!(result.contains("Test | commit | with | pipes"));
1002    }
1003
1004    #[test]
1005    fn test_prepare_frontmatter_tags() {
1006        let mut commit_saver = create_test_commit_saver();
1007
1008        let tags = commit_saver.prepare_frontmatter_tags();
1009
1010        assert_eq!(tags.len(), 3);
1011        assert!(tags.contains(&"#datetime/days/Monday".to_string()));
1012        assert!(tags.contains(&"#diary/commits".to_string()));
1013    }
1014
1015    #[test]
1016    fn test_append_entry_to_diary() -> Result<(), Box<dyn std::error::Error>> {
1017        let mut commit_saver = create_test_commit_saver();
1018        let temp_dir = tempdir()?;
1019        let file_path = temp_dir.path().join("test_diary.md");
1020
1021        // Create the file first
1022        File::create(&file_path)?;
1023
1024        let result = commit_saver.append_entry_to_diary(&file_path, "%H:%M:%S");
1025
1026        assert!(result.is_ok());
1027
1028        // Verify content was written
1029        let content = fs::read_to_string(&file_path)?;
1030        assert!(content.contains("Test commit message"));
1031        assert!(content.contains("abc123def456"));
1032
1033        Ok(())
1034    }
1035
1036    #[test]
1037    fn test_append_entry_to_diary_file_not_exists() {
1038        let mut commit_saver = create_test_commit_saver();
1039        let non_existent_path = PathBuf::from("/non/existent/file.md");
1040
1041        let result = commit_saver.append_entry_to_diary(&non_existent_path, "%H:%M:%S");
1042
1043        assert!(result.is_err());
1044    }
1045
1046    #[test]
1047    fn test_prepare_path_for_commit_integration() {
1048        let mut commit_saver = create_test_commit_saver();
1049        let obsidian_path = PathBuf::from("TestDiaries/Commits");
1050        let date_template = "%Y/%m-%B/%F.md";
1051
1052        let result = commit_saver.prepare_path_for_commit(&obsidian_path, date_template);
1053
1054        // Should contain the formatted path
1055        assert!(result.contains("/TestDiaries/Commits/"));
1056        assert!(result.contains("2023"));
1057        assert!(result.contains("12-December"));
1058        // assert!(result.ends_with(".md"));
1059        assert!(
1060            std::path::Path::new(&result)
1061                .extension()
1062                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
1063        );
1064    }
1065
1066    #[test]
1067    fn test_create_diary_file_error_handling() {
1068        let mut commit_saver = create_test_commit_saver();
1069
1070        // Try to create file in a path that will fail (read-only location)
1071        let result = create_diary_file("/proc/invalid_path/file.md", &mut commit_saver);
1072
1073        // Should return an error
1074        assert!(result.is_err());
1075    }
1076
1077    #[test]
1078    fn test_get_parent_from_full_path_edge_cases() {
1079        use std::path::Path;
1080
1081        // Test with a simple path
1082        let path = Path::new("/home/user/file.txt");
1083        let parent = get_parent_from_full_path(path);
1084        assert!(parent.is_ok());
1085        assert_eq!(parent.unwrap(), Path::new("/home/user"));
1086
1087        // Test with nested path
1088        let nested = Path::new("/a/b/c/d/e/file.txt");
1089        let nested_parent = get_parent_from_full_path(nested);
1090        assert!(nested_parent.is_ok());
1091    }
1092
1093    #[test]
1094    fn test_commit_saver_default_in_git_repo() {
1095        use git2::Repository;
1096
1097        // Only run if we're in a git repo
1098        if Repository::discover("./").is_ok() {
1099            let commit_saver = CommitSaver::default();
1100
1101            // Verify all fields are populated
1102            assert!(!commit_saver.repository_url.is_empty());
1103            assert!(!commit_saver.commit_branch_name.is_empty());
1104            assert!(!commit_saver.commit_hash.is_empty());
1105            assert!(!commit_saver.commit_msg.is_empty());
1106
1107            // Hash should be 40 characters (SHA-1)
1108            assert_eq!(commit_saver.commit_hash.len(), 40);
1109        }
1110    }
1111
1112    #[test]
1113    fn test_prepare_path_for_commit_with_empty_template() {
1114        let mut commit_saver = create_test_commit_saver();
1115        let obsidian_path = PathBuf::from("Diaries");
1116        let empty_template = "";
1117
1118        let result = commit_saver.prepare_path_for_commit(&obsidian_path, empty_template);
1119
1120        // Should still produce a path even with empty template
1121        assert!(result.contains("Diaries"));
1122    }
1123
1124    #[test]
1125    fn test_commit_msg_with_only_whitespace_lines() {
1126        let commit_saver = CommitSaver {
1127            repository_url: "test".to_string(),
1128            commit_branch_name: "main".to_string(),
1129            commit_hash: "abc123".to_string(),
1130            commit_msg: "   \n\n   \n".to_string(), // Only whitespace
1131            commit_datetime: Utc.with_ymd_and_hms(2023, 12, 25, 10, 30, 0).unwrap(),
1132        };
1133
1134        // commit_msg should be empty or minimal after filtering
1135        assert!(commit_saver.commit_msg.is_empty() || commit_saver.commit_msg.len() < 10);
1136    }
1137
1138    #[test]
1139    fn test_create_diary_file_frontmatter_formatting() -> Result<(), Box<dyn std::error::Error>> {
1140        let temp_dir = tempdir()?;
1141        let file_path = temp_dir.path().join("diary.md");
1142        let mut commit_saver = create_test_commit_saver();
1143
1144        create_diary_file(file_path.to_str().unwrap(), &mut commit_saver)?;
1145
1146        let content = fs::read_to_string(&file_path)?;
1147
1148        // Verify frontmatter structure
1149        assert!(content.starts_with("---"));
1150        assert!(content.contains("category: diary"));
1151        assert!(content.contains("section: commits"));
1152        assert!(content.contains("tags:"));
1153        assert!(content.contains("#diary/commits"));
1154
1155        Ok(())
1156    }
1157
1158    #[test]
1159    fn test_diary_file_entry_markup_generation() {
1160        let frontmatter = vec![
1161            "#datetime/week/52".to_string(),
1162            "#datetime/days/Saturday".to_string(),
1163            "#diary/commits".to_string(),
1164        ];
1165        let diary_date = "2023-12-30".to_string();
1166
1167        let markup = DiaryFileEntry {
1168            frontmatter,
1169            diary_date,
1170        };
1171
1172        let output = markup.to_string();
1173
1174        // Verify markup structure
1175        assert!(output.contains("---"));
1176        assert!(output.contains("category: diary"));
1177        assert!(output.contains("#datetime/week/52"));
1178        assert!(output.contains("#datetime/days/Saturday"));
1179        assert!(output.contains("2023-12-30"));
1180        assert!(output.contains("| FOLDER | TIME | COMMIT MESSAGE"));
1181    }
1182
1183    #[test]
1184    fn test_commit_saver_default_no_origin_remote() {
1185        use git2::{Repository, Signature};
1186        use tempfile::tempdir;
1187
1188        let temp_dir = tempdir().unwrap();
1189        let repo = Repository::init(temp_dir.path()).unwrap();
1190
1191        // Create a commit so HEAD exists (required for peel_to_commit)
1192        let sig = Signature::now("Test User", "test@example.com").unwrap();
1193        let tree_id = repo.index().unwrap().write_tree().unwrap();
1194        let tree = repo.find_tree(tree_id).unwrap();
1195        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1196            .unwrap();
1197
1198        // Build directly from the repo handle. This deliberately avoids mutating
1199        // the process-global current directory, so the test stays isolated and
1200        // can run in parallel with others that call `Repository::discover("./")`.
1201        // The repo has no "origin" remote, so this exercises the `no_url_set` branch.
1202        let saver = CommitSaver::from_repo(&repo).expect("from_repo should succeed");
1203
1204        assert_eq!(saver.repository_url, "no_url_set");
1205        // Branch name depends on git config; just verify it's not empty
1206        assert!(!saver.commit_branch_name.is_empty());
1207        assert!(!saver.commit_hash.is_empty());
1208    }
1209
1210    // US-02: CommitSaver construction error branches
1211
1212    #[test]
1213    fn test_from_repo_no_head_error() {
1214        use git2::Repository;
1215
1216        let temp_dir = tempdir().unwrap();
1217        let repo = Repository::init(temp_dir.path()).unwrap();
1218
1219        // Repository with no commits → no HEAD
1220        // from_repo should hit git_repo.head()? error arm
1221        let result = CommitSaver::from_repo(&repo);
1222
1223        assert!(result.is_err(), "from_repo should error on no-HEAD repo");
1224    }
1225
1226    #[test]
1227    fn test_from_repo_detached_head_branch_head() {
1228        use git2::{Repository, Signature};
1229
1230        let temp_dir = tempdir().unwrap();
1231        let repo = Repository::init(temp_dir.path()).unwrap();
1232
1233        // Create initial commit
1234        let sig = Signature::now("Test User", "test@example.com").unwrap();
1235        let tree_id = repo.index().unwrap().write_tree().unwrap();
1236        let tree = repo.find_tree(tree_id).unwrap();
1237        let commit_oid = repo
1238            .commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
1239            .unwrap();
1240
1241        // Detach HEAD by pointing directly to the commit
1242        repo.set_head_detached(commit_oid).unwrap();
1243
1244        // from_repo should succeed with detached HEAD and record "HEAD" as branch name
1245        // (head.shorthand() returns Some("HEAD") for detached HEAD, not None)
1246        let saver =
1247            CommitSaver::from_repo(&repo).expect("from_repo should succeed on detached HEAD");
1248
1249        assert_eq!(
1250            saver.commit_branch_name, "HEAD",
1251            "detached HEAD should record 'HEAD' as branch name"
1252        );
1253    }
1254
1255    #[test]
1256    #[ignore = "DISTILL scaffold — documented unreachable"]
1257    fn test_from_repo_out_of_range_timestamp_unreachable() {
1258        // DOCUMENTED-UNREACHABLE: The error arm for out-of-range timestamps in from_repo
1259        // (line 173-174: DateTime::from_timestamp(...).ok_or(...)) cannot be triggered
1260        // with representable timestamp values in git2's environment.
1261        //
1262        // Per upstream-issues.md: libgit2 appears to constrain stored commit times within
1263        // chrono's representable range (~±262143 years). The defensive guard remains in
1264        // production code; this test documents that it is not practically coverable via
1265        // git2-crafted commits.
1266        //
1267        // This test remains ignored until evidence emerges of a git2-compatible way to
1268        // craft a commit with a timestamp beyond chrono's bound.
1269    }
1270
1271    #[test]
1272    fn test_try_new_discovery_failure_blocked() {
1273        // Tests that try_discover fails when called on a path that is not
1274        // inside a Git repository. Uses tempfile::tempdir() to create an
1275        // isolated non-repo directory, preventing any discovery walk from
1276        // finding a parent repository.
1277        let non_repo_dir = tempdir().expect("Failed to create temp dir");
1278        let result = CommitSaver::try_discover(non_repo_dir.path());
1279
1280        assert!(
1281            result.is_err(),
1282            "try_discover should fail when path is not in a git repository"
1283        );
1284    }
1285
1286    // US-03: Filesystem error branches
1287
1288    #[test]
1289    fn test_append_entry_to_diary_parent_not_exists() {
1290        let mut commit_saver = create_test_commit_saver();
1291
1292        // Use a tempdir path but point to a non-existent parent
1293        let temp_dir = tempdir().unwrap();
1294        let missing_parent_path = temp_dir.path().join("nonexistent").join("diary.md");
1295
1296        // append_entry_to_diary opens with append mode; file must exist.
1297        // Parent doesn't exist, so open should fail.
1298        let result = commit_saver.append_entry_to_diary(&missing_parent_path, "%H:%M:%S");
1299
1300        assert!(
1301            result.is_err(),
1302            "append_entry_to_diary should error on missing parent"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_create_diary_file_unwritable_location() {
1308        let mut commit_saver = create_test_commit_saver();
1309
1310        // Try to create file in /proc (read-only on Linux)
1311        let result = create_diary_file("/proc/invalid_path/file.md", &mut commit_saver);
1312
1313        assert!(
1314            result.is_err(),
1315            "create_diary_file should error on unwritable location"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_create_directories_forbidden_path() {
1321        let forbidden_path = std::path::Path::new("/proc/invalid/path/diary.md");
1322
1323        // create_directories_for_new_entry calls fs::create_dir_all on the parent.
1324        // /proc is read-only, so this should fail.
1325        let result = create_directories_for_new_entry(forbidden_path);
1326
1327        assert!(
1328            result.is_err(),
1329            "create_directories_for_new_entry should error on forbidden path"
1330        );
1331    }
1332
1333    // US-04: Path inspection boundary branches
1334
1335    #[test]
1336    fn test_check_diary_path_exists_missing_error() {
1337        let temp_dir = tempdir().unwrap();
1338        let missing_path = temp_dir.path().join("nonexistent.md");
1339
1340        let result = check_diary_path_exists(&missing_path);
1341
1342        assert!(
1343            result.is_err(),
1344            "check_diary_path_exists should error on missing path"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_check_diary_path_exists_happy_path() -> Result<(), Box<dyn std::error::Error>> {
1350        let temp_dir = tempdir()?;
1351        let existing_path = temp_dir.path().join("diary.md");
1352
1353        // Create the file
1354        File::create(&existing_path)?;
1355
1356        let result = check_diary_path_exists(&existing_path);
1357
1358        assert!(
1359            result.is_ok(),
1360            "check_diary_path_exists should succeed on existing path"
1361        );
1362        Ok(())
1363    }
1364
1365    #[test]
1366    fn test_get_parent_from_full_path_root_error() {
1367        let root = std::path::Path::new("/");
1368
1369        let result = get_parent_from_full_path(root);
1370
1371        assert!(
1372            result.is_err(),
1373            "get_parent_from_full_path should error on root path"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_get_parent_from_full_path_nested_happy() {
1379        let path = std::path::Path::new("/home/user/file.txt");
1380
1381        let result = get_parent_from_full_path(path);
1382
1383        assert!(
1384            result.is_ok(),
1385            "get_parent_from_full_path should succeed on nested path"
1386        );
1387        assert_eq!(result.unwrap(), std::path::Path::new("/home/user"));
1388    }
1389
1390    #[test]
1391    fn test_repo_workdir_name_returns_basename() {
1392        // A repo checked out at .../claude-src reports its name as "claude-src",
1393        // no ambient current-directory dependency.
1394        let temp_dir = tempdir().unwrap();
1395        let repo_path = temp_dir.path().join("claude-src");
1396        fs::create_dir(&repo_path).unwrap();
1397        let repo = Repository::init(&repo_path).unwrap();
1398
1399        assert_eq!(repo_workdir_name(&repo).as_deref(), Some("claude-src"));
1400    }
1401
1402    #[test]
1403    fn test_is_repo_excluded_matches_exact_name() {
1404        let list = vec!["claude-src".to_string(), "foo".to_string()];
1405        assert!(is_repo_excluded("claude-src", &list));
1406        assert!(is_repo_excluded("foo", &list));
1407    }
1408
1409    #[test]
1410    fn test_is_repo_excluded_rejects_non_member() {
1411        let list = vec!["claude-src".to_string()];
1412        assert!(!is_repo_excluded("rusty-commit-saver", &list));
1413        // No partial / prefix matching.
1414        assert!(!is_repo_excluded("claude-src-2", &list));
1415    }
1416
1417    #[test]
1418    fn test_is_repo_excluded_empty_list_excludes_nothing() {
1419        assert!(!is_repo_excluded("claude-src", &[]));
1420    }
1421
1422    #[test]
1423    fn test_is_repo_excluded_is_case_sensitive() {
1424        let list = vec!["claude-src".to_string()];
1425        assert!(!is_repo_excluded("Claude-Src", &list));
1426    }
1427
1428    #[test]
1429    fn test_repo_name_from_url_variants() {
1430        // Every common remote form for the same repo resolves to "claude-src".
1431        for url in [
1432            "git@github.com:chess-seventh/claude-src.git",
1433            "https://github.com/chess-seventh/claude-src.git",
1434            "ssh://git@github.com/chess-seventh/claude-src.git",
1435            "https://github.com/chess-seventh/claude-src",
1436            "git@github.com:claude-src.git",
1437            "/home/seventh/src/claude-src",
1438            "/home/seventh/src/claude-src/",
1439        ] {
1440            assert_eq!(
1441                repo_name_from_url(url).as_deref(),
1442                Some("claude-src"),
1443                "wrong repo name for url: {url}"
1444            );
1445        }
1446    }
1447
1448    #[test]
1449    fn test_repo_name_from_url_rejects_empty_and_sentinel() {
1450        assert_eq!(repo_name_from_url(""), None);
1451        assert_eq!(repo_name_from_url("   "), None);
1452        assert_eq!(repo_name_from_url("no_url_set"), None);
1453    }
1454
1455    #[test]
1456    fn test_canonical_repo_name_prefers_origin_over_workdir() {
1457        // The regression this lane fixes: a repo checked out in a directory
1458        // whose basename is NOT the repo name (e.g. a git worktree named after
1459        // the lane) must still resolve to its canonical origin name, so one
1460        // exclude entry covers every worktree.
1461        let temp_dir = tempdir().unwrap();
1462        let repo_path = temp_dir.path().join("some-lane-worktree");
1463        fs::create_dir(&repo_path).unwrap();
1464        let repo = Repository::init(&repo_path).unwrap();
1465        repo.remote("origin", "git@github.com:chess-seventh/claude-src.git")
1466            .unwrap();
1467
1468        assert_eq!(canonical_repo_name(&repo).as_deref(), Some("claude-src"));
1469    }
1470
1471    #[test]
1472    fn test_canonical_repo_name_falls_back_to_workdir_without_origin() {
1473        // No origin remote (local-only repo): fall back to the workdir basename.
1474        let temp_dir = tempdir().unwrap();
1475        let repo_path = temp_dir.path().join("claude-src");
1476        fs::create_dir(&repo_path).unwrap();
1477        let repo = Repository::init(&repo_path).unwrap();
1478
1479        assert_eq!(canonical_repo_name(&repo).as_deref(), Some("claude-src"));
1480    }
1481}