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