Skip to main content

toolpath_git/
lib.rs

1//! Derive Toolpath provenance documents from git repository history.
2//!
3//! This crate converts git commit history into Toolpath [`Document`]s,
4//! mapping branches to [`Path`]s and multi-branch views to [`Graph`]s.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use toolpath_git::{derive, DeriveConfig};
10//!
11//! let repo = git2::Repository::open(".")?;
12//! let config = DeriveConfig {
13//!     remote: "origin".into(),
14//!     title: None,
15//!     base: None,
16//! };
17//!
18//! // Single branch produces a Path document
19//! let doc = derive(&repo, &["main".into()], &config)?;
20//!
21//! // Multiple branches produce a Graph document
22//! let doc = derive(&repo, &["main".into(), "feature".into()], &config)?;
23//! # Ok::<(), anyhow::Error>(())
24//! ```
25
26use anyhow::{Context, Result};
27use chrono::{DateTime, Utc};
28use git2::{Commit, DiffOptions, Oid, Repository};
29use std::collections::HashMap;
30use toolpath::v1::{
31    ActorDefinition, ArtifactChange, Base, Document, Graph, GraphIdentity, GraphMeta, Identity,
32    Path, PathIdentity, PathMeta, PathOrRef, Step, StepIdentity, StepMeta, VcsSource,
33};
34
35// ============================================================================
36// Public configuration and types
37// ============================================================================
38
39/// Configuration for deriving Toolpath documents from a git repository.
40pub struct DeriveConfig {
41    /// Remote name for URI generation (e.g., "origin").
42    pub remote: String,
43    /// Optional title for graph output.
44    pub title: Option<String>,
45    /// Global base commit override (overrides per-branch starts).
46    pub base: Option<String>,
47}
48
49/// Parsed branch specification.
50///
51/// Branches can be specified as `"name"` or `"name:start"` where `start` is a
52/// revision expression indicating where the path should begin.
53#[derive(Debug, Clone)]
54pub struct BranchSpec {
55    pub name: String,
56    pub start: Option<String>,
57}
58
59impl BranchSpec {
60    /// Parse a branch specification string.
61    ///
62    /// Format: `"name"` or `"name:start"`.
63    pub fn parse(s: &str) -> Self {
64        if let Some((name, start)) = s.split_once(':') {
65            BranchSpec {
66                name: name.to_string(),
67                start: Some(start.to_string()),
68            }
69        } else {
70            BranchSpec {
71                name: s.to_string(),
72                start: None,
73            }
74        }
75    }
76}
77
78// ============================================================================
79// Public API
80// ============================================================================
81
82/// Derive a Toolpath [`Document`] from the given repository and branch names.
83///
84/// Branch strings are parsed as [`BranchSpec`]s (supporting `"name:start"` syntax).
85/// A single branch produces a [`Document::Path`]; multiple branches produce a
86/// [`Document::Graph`].
87pub fn derive(repo: &Repository, branches: &[String], config: &DeriveConfig) -> Result<Document> {
88    let branch_specs: Vec<BranchSpec> = branches.iter().map(|s| BranchSpec::parse(s)).collect();
89
90    if branch_specs.len() == 1 {
91        let path_doc = derive_path(repo, &branch_specs[0], config)?;
92        Ok(Document::Path(path_doc))
93    } else {
94        let graph_doc = derive_graph(repo, &branch_specs, config)?;
95        Ok(Document::Graph(graph_doc))
96    }
97}
98
99/// Derive a Toolpath [`Path`] from a single branch specification.
100pub fn derive_path(repo: &Repository, spec: &BranchSpec, config: &DeriveConfig) -> Result<Path> {
101    let repo_uri = get_repo_uri(repo, &config.remote)?;
102
103    let branch_ref = repo
104        .find_branch(&spec.name, git2::BranchType::Local)
105        .with_context(|| format!("Branch '{}' not found", spec.name))?;
106    let branch_commit = branch_ref.get().peel_to_commit()?;
107
108    // Determine base commit
109    let base_oid = if let Some(global_base) = &config.base {
110        // Global base overrides per-branch
111        let obj = repo
112            .revparse_single(global_base)
113            .with_context(|| format!("Failed to parse base ref '{}'", global_base))?;
114        obj.peel_to_commit()?.id()
115    } else if let Some(start) = &spec.start {
116        // Per-branch start commit - resolve relative to the branch
117        // e.g., "main:HEAD~5" means 5 commits before main's HEAD
118        let start_ref = if let Some(rest) = start.strip_prefix("HEAD") {
119            // Replace HEAD with the branch name for relative refs
120            format!("{}{}", spec.name, rest)
121        } else {
122            start.clone()
123        };
124        let obj = repo.revparse_single(&start_ref).with_context(|| {
125            format!(
126                "Failed to parse start ref '{}' (resolved to '{}') for branch '{}'",
127                start, start_ref, spec.name
128            )
129        })?;
130        obj.peel_to_commit()?.id()
131    } else {
132        // Default: find merge-base with default branch
133        find_base_for_branch(repo, &branch_commit)?
134    };
135
136    let base_commit = repo.find_commit(base_oid)?;
137
138    // Collect commits from base to head
139    let commits = collect_commits(repo, base_oid, branch_commit.id())?;
140
141    // Generate steps and collect actor definitions
142    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
143    let steps = generate_steps(repo, &commits, base_oid, &mut actors)?;
144
145    // Build path document
146    let head_step_id = if steps.is_empty() {
147        format!("step-{}", short_oid(branch_commit.id()))
148    } else {
149        steps.last().unwrap().step.id.clone()
150    };
151
152    Ok(Path {
153        path: PathIdentity {
154            id: format!("path-{}", spec.name.replace('/', "-")),
155            base: Some(Base {
156                uri: repo_uri,
157                ref_str: Some(base_commit.id().to_string()),
158            }),
159            head: head_step_id,
160        },
161        steps,
162        meta: Some(PathMeta {
163            title: Some(format!("Branch: {}", spec.name)),
164            actors: if actors.is_empty() {
165                None
166            } else {
167                Some(actors)
168            },
169            ..Default::default()
170        }),
171    })
172}
173
174/// Derive a Toolpath [`Graph`] from multiple branch specifications.
175pub fn derive_graph(
176    repo: &Repository,
177    branch_specs: &[BranchSpec],
178    config: &DeriveConfig,
179) -> Result<Graph> {
180    // Find the default branch name
181    let default_branch = find_default_branch(repo);
182
183    // If the default branch is included without an explicit start, compute the earliest
184    // merge-base among all other branches to use as its starting point
185    let default_branch_start = compute_default_branch_start(repo, branch_specs, &default_branch)?;
186
187    // Generate paths for each branch with its own base
188    let mut paths = Vec::new();
189    for spec in branch_specs {
190        // Check if this is the default branch and needs special handling
191        let effective_spec = if default_branch_start.is_some()
192            && spec.start.is_none()
193            && default_branch.as_ref() == Some(&spec.name)
194        {
195            BranchSpec {
196                name: spec.name.clone(),
197                start: default_branch_start.clone(),
198            }
199        } else {
200            spec.clone()
201        };
202        let path_doc = derive_path(repo, &effective_spec, config)?;
203        paths.push(PathOrRef::Path(Box::new(path_doc)));
204    }
205
206    // Create graph ID from branch names
207    let branch_names: Vec<&str> = branch_specs.iter().map(|s| s.name.as_str()).collect();
208    let graph_id = if branch_names.len() <= 3 {
209        format!(
210            "graph-{}",
211            branch_names
212                .iter()
213                .map(|b| b.replace('/', "-"))
214                .collect::<Vec<_>>()
215                .join("-")
216        )
217    } else {
218        format!("graph-{}-branches", branch_names.len())
219    };
220
221    let title = config
222        .title
223        .clone()
224        .unwrap_or_else(|| format!("Branches: {}", branch_names.join(", ")));
225
226    Ok(Graph {
227        graph: GraphIdentity { id: graph_id },
228        paths,
229        meta: Some(GraphMeta {
230            title: Some(title),
231            ..Default::default()
232        }),
233    })
234}
235
236// ============================================================================
237// Public utility functions
238// ============================================================================
239
240/// Get the repository URI from a remote, falling back to a file:// URI.
241pub fn get_repo_uri(repo: &Repository, remote_name: &str) -> Result<String> {
242    if let Ok(remote) = repo.find_remote(remote_name)
243        && let Some(url) = remote.url()
244    {
245        return Ok(normalize_git_url(url));
246    }
247
248    // Fall back to file path
249    if let Some(path) = repo.path().parent() {
250        return Ok(format!("file://{}", path.display()));
251    }
252
253    Ok("file://unknown".to_string())
254}
255
256/// Normalize a git remote URL to a canonical short form.
257///
258/// Converts common hosting URLs to compact identifiers:
259/// - `git@github.com:org/repo.git` -> `github:org/repo`
260/// - `https://github.com/org/repo.git` -> `github:org/repo`
261/// - `git@gitlab.com:org/repo.git` -> `gitlab:org/repo`
262/// - `https://gitlab.com/org/repo.git` -> `gitlab:org/repo`
263///
264/// # Examples
265///
266/// ```
267/// use toolpath_git::normalize_git_url;
268///
269/// assert_eq!(normalize_git_url("git@github.com:org/repo.git"), "github:org/repo");
270/// assert_eq!(normalize_git_url("https://gitlab.com/org/repo"), "gitlab:org/repo");
271///
272/// // Unknown hosts pass through unchanged
273/// assert_eq!(
274///     normalize_git_url("https://bitbucket.org/org/repo"),
275///     "https://bitbucket.org/org/repo",
276/// );
277/// ```
278pub fn normalize_git_url(url: &str) -> String {
279    if let Some(rest) = url.strip_prefix("git@github.com:") {
280        let repo = rest.trim_end_matches(".git");
281        return format!("github:{}", repo);
282    }
283
284    if let Some(rest) = url.strip_prefix("https://github.com/") {
285        let repo = rest.trim_end_matches(".git");
286        return format!("github:{}", repo);
287    }
288
289    if let Some(rest) = url.strip_prefix("git@gitlab.com:") {
290        let repo = rest.trim_end_matches(".git");
291        return format!("gitlab:{}", repo);
292    }
293
294    if let Some(rest) = url.strip_prefix("https://gitlab.com/") {
295        let repo = rest.trim_end_matches(".git");
296        return format!("gitlab:{}", repo);
297    }
298
299    // Return as-is for other URLs
300    url.to_string()
301}
302
303/// Create a URL-safe slug from a git author name and email.
304///
305/// Prefers the email username; falls back to the name.
306///
307/// # Examples
308///
309/// ```
310/// use toolpath_git::slugify_author;
311///
312/// assert_eq!(slugify_author("Alex Smith", "asmith@example.com"), "asmith");
313/// assert_eq!(slugify_author("Alex Smith", "unknown"), "alex-smith");
314/// ```
315pub fn slugify_author(name: &str, email: &str) -> String {
316    // Try to extract username from email
317    if let Some(username) = email.split('@').next()
318        && !username.is_empty()
319        && username != email
320    {
321        return username
322            .to_lowercase()
323            .chars()
324            .map(|c| if c.is_alphanumeric() { c } else { '-' })
325            .collect();
326    }
327
328    // Fall back to name
329    name.to_lowercase()
330        .chars()
331        .map(|c| if c.is_alphanumeric() { c } else { '-' })
332        .collect::<String>()
333        .trim_matches('-')
334        .to_string()
335}
336
337// ============================================================================
338// Listing / discovery
339// ============================================================================
340
341/// Summary information about a local branch.
342#[derive(Debug, Clone)]
343pub struct BranchInfo {
344    /// Branch name (e.g., "main", "feature/foo").
345    pub name: String,
346    /// Short (8-char) hex of the tip commit.
347    pub head_short: String,
348    /// Full hex OID of the tip commit.
349    pub head: String,
350    /// First line of the tip commit message.
351    pub subject: String,
352    /// Author name of the tip commit.
353    pub author: String,
354    /// ISO 8601 timestamp of the tip commit.
355    pub timestamp: String,
356}
357
358/// List local branches with summary metadata.
359pub fn list_branches(repo: &Repository) -> Result<Vec<BranchInfo>> {
360    let mut branches = Vec::new();
361
362    for branch_result in repo.branches(Some(git2::BranchType::Local))? {
363        let (branch, _) = branch_result?;
364        let name = branch.name()?.unwrap_or("<invalid utf-8>").to_string();
365
366        let commit = branch.get().peel_to_commit()?;
367
368        let author = commit.author();
369        let author_name = author.name().unwrap_or("unknown").to_string();
370
371        let time = commit.time();
372        let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
373            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
374            .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
375
376        let subject = commit
377            .message()
378            .unwrap_or("")
379            .lines()
380            .next()
381            .unwrap_or("")
382            .to_string();
383
384        branches.push(BranchInfo {
385            name,
386            head_short: short_oid(commit.id()),
387            head: commit.id().to_string(),
388            subject,
389            author: author_name,
390            timestamp,
391        });
392    }
393
394    branches.sort_by(|a, b| a.name.cmp(&b.name));
395    Ok(branches)
396}
397
398// ============================================================================
399// Private helpers
400// ============================================================================
401
402/// When the default branch is included in a multi-branch graph without an explicit start,
403/// compute the earliest merge-base among all feature branches to use as main's start.
404/// This ensures we see main's commits back to where the earliest feature diverged.
405fn compute_default_branch_start(
406    repo: &Repository,
407    branch_specs: &[BranchSpec],
408    default_branch: &Option<String>,
409) -> Result<Option<String>> {
410    let default_name = match default_branch {
411        Some(name) => name,
412        None => return Ok(None),
413    };
414
415    // Check if the default branch is in the list and doesn't have an explicit start
416    let default_in_list = branch_specs
417        .iter()
418        .any(|s| &s.name == default_name && s.start.is_none());
419    if !default_in_list {
420        return Ok(None);
421    }
422
423    // Get the default branch commit
424    let default_ref = repo.find_branch(default_name, git2::BranchType::Local)?;
425    let default_commit = default_ref.get().peel_to_commit()?;
426
427    // Find the earliest merge-base among all non-default branches
428    let mut earliest_base: Option<Oid> = None;
429
430    for spec in branch_specs {
431        if &spec.name == default_name {
432            continue;
433        }
434
435        let branch_ref = match repo.find_branch(&spec.name, git2::BranchType::Local) {
436            Ok(r) => r,
437            Err(_) => continue,
438        };
439        let branch_commit = match branch_ref.get().peel_to_commit() {
440            Ok(c) => c,
441            Err(_) => continue,
442        };
443
444        if let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id()) {
445            // Check if this merge-base is earlier (ancestor of) current earliest
446            match earliest_base {
447                None => earliest_base = Some(merge_base),
448                Some(current) => {
449                    // If merge_base is an ancestor of current, use merge_base
450                    // (it's "earlier" in the commit history)
451                    if repo.merge_base(merge_base, current).ok() == Some(merge_base)
452                        && merge_base != current
453                    {
454                        earliest_base = Some(merge_base);
455                    }
456                }
457            }
458        }
459    }
460
461    // Use the GRANDPARENT of the earliest merge-base so both the merge-base and its parent
462    // are included in main's steps. This avoids showing an orphan BASE node.
463    if let Some(base_oid) = earliest_base
464        && let Ok(base_commit) = repo.find_commit(base_oid)
465        && base_commit.parent_count() > 0
466        && let Ok(parent) = base_commit.parent(0)
467    {
468        // Try to get grandparent
469        if parent.parent_count() > 0
470            && let Ok(grandparent) = parent.parent(0)
471        {
472            return Ok(Some(grandparent.id().to_string()));
473        }
474        // Fall back to parent if no grandparent
475        return Ok(Some(parent.id().to_string()));
476    }
477
478    Ok(earliest_base.map(|oid| oid.to_string()))
479}
480
481fn find_base_for_branch(repo: &Repository, branch_commit: &Commit) -> Result<Oid> {
482    // Try to find merge-base with default branch, but only if the branch
483    // being derived is *not* the default branch itself (merge-base of a
484    // branch with itself is its own tip, which yields zero commits).
485    if let Some(default_branch) = find_default_branch(repo)
486        && let Ok(default_ref) = repo.find_branch(&default_branch, git2::BranchType::Local)
487        && let Ok(default_commit) = default_ref.get().peel_to_commit()
488        && default_commit.id() != branch_commit.id()
489        && let Ok(merge_base) = repo.merge_base(default_commit.id(), branch_commit.id())
490        && merge_base != branch_commit.id()
491    {
492        return Ok(merge_base);
493    }
494
495    // Fall back to first commit in history (root of the branch)
496    let mut walker = repo.revwalk()?;
497    walker.push(branch_commit.id())?;
498    walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
499
500    if let Some(Ok(oid)) = walker.next() {
501        return Ok(oid);
502    }
503
504    Ok(branch_commit.id())
505}
506
507fn find_default_branch(repo: &Repository) -> Option<String> {
508    // Try common default branch names
509    for name in &["main", "master", "trunk", "develop"] {
510        if repo.find_branch(name, git2::BranchType::Local).is_ok() {
511            return Some(name.to_string());
512        }
513    }
514    None
515}
516
517fn collect_commits<'a>(
518    repo: &'a Repository,
519    base_oid: Oid,
520    head_oid: Oid,
521) -> Result<Vec<Commit<'a>>> {
522    let mut walker = repo.revwalk()?;
523    walker.push(head_oid)?;
524    walker.hide(base_oid)?;
525    walker.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
526
527    let mut commits = Vec::new();
528    for oid_result in walker {
529        let oid = oid_result?;
530        let commit = repo.find_commit(oid)?;
531        commits.push(commit);
532    }
533
534    Ok(commits)
535}
536
537fn generate_steps(
538    repo: &Repository,
539    commits: &[Commit],
540    base_oid: Oid,
541    actors: &mut HashMap<String, ActorDefinition>,
542) -> Result<Vec<Step>> {
543    let mut steps = Vec::new();
544
545    for commit in commits {
546        let step = commit_to_step(repo, commit, base_oid, actors)?;
547        steps.push(step);
548    }
549
550    Ok(steps)
551}
552
553fn commit_to_step(
554    repo: &Repository,
555    commit: &Commit,
556    base_oid: Oid,
557    actors: &mut HashMap<String, ActorDefinition>,
558) -> Result<Step> {
559    let step_id = format!("step-{}", short_oid(commit.id()));
560
561    // Filter parents to only include those that aren't the base commit
562    let parents: Vec<String> = commit
563        .parent_ids()
564        .filter(|pid| *pid != base_oid)
565        .map(|pid| format!("step-{}", short_oid(pid)))
566        .collect();
567
568    // Get author info
569    let author = commit.author();
570    let author_name = author.name().unwrap_or("unknown");
571    let author_email = author.email().unwrap_or("unknown");
572    let actor = format!("human:{}", slugify_author(author_name, author_email));
573
574    // Register actor definition
575    actors.entry(actor.clone()).or_insert_with(|| {
576        let mut identities = Vec::new();
577        if author_email != "unknown" {
578            identities.push(Identity {
579                system: "email".to_string(),
580                id: author_email.to_string(),
581            });
582        }
583        ActorDefinition {
584            name: Some(author_name.to_string()),
585            identities,
586            ..Default::default()
587        }
588    });
589
590    // Get timestamp
591    let time = commit.time();
592    let timestamp = DateTime::<Utc>::from_timestamp(time.seconds(), 0)
593        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
594        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
595
596    // Generate diff
597    let change = generate_diff(repo, commit)?;
598
599    // Get commit message as intent
600    let message = commit.message().unwrap_or("").trim();
601    let intent = if message.is_empty() {
602        None
603    } else {
604        // Use first line of commit message
605        Some(message.lines().next().unwrap_or(message).to_string())
606    };
607
608    // VCS source reference
609    let source = VcsSource {
610        vcs_type: "git".to_string(),
611        revision: commit.id().to_string(),
612        change_id: None,
613    };
614
615    Ok(Step {
616        step: StepIdentity {
617            id: step_id,
618            parents,
619            actor,
620            timestamp,
621        },
622        change,
623        meta: Some(StepMeta {
624            intent,
625            source: Some(source),
626            ..Default::default()
627        }),
628    })
629}
630
631fn generate_diff(repo: &Repository, commit: &Commit) -> Result<HashMap<String, ArtifactChange>> {
632    let tree = commit.tree()?;
633
634    let parent_tree = if commit.parent_count() > 0 {
635        Some(commit.parent(0)?.tree()?)
636    } else {
637        None
638    };
639
640    let mut diff_opts = DiffOptions::new();
641    diff_opts.context_lines(3);
642
643    let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;
644
645    let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
646    let mut current_file: Option<String> = None;
647    let mut current_diff = String::new();
648
649    diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
650        let file_path = delta
651            .new_file()
652            .path()
653            .or_else(|| delta.old_file().path())
654            .map(|p| p.to_string_lossy().to_string());
655
656        if let Some(path) = file_path {
657            // Check if we're starting a new file
658            if current_file.as_ref() != Some(&path) {
659                // Save previous file's diff
660                if let Some(prev_file) = current_file.take()
661                    && !current_diff.is_empty()
662                {
663                    changes.insert(prev_file, ArtifactChange::raw(&current_diff));
664                }
665                current_file = Some(path);
666                current_diff.clear();
667            }
668        }
669
670        // Append line to current diff
671        let prefix = match line.origin() {
672            '+' => "+",
673            '-' => "-",
674            ' ' => " ",
675            '>' => ">",
676            '<' => "<",
677            'F' => "",  // File header
678            'H' => "@", // Hunk header - we'll handle this specially
679            'B' => "",
680            _ => "",
681        };
682
683        if line.origin() == 'H' {
684            // Hunk header
685            if let Ok(content) = std::str::from_utf8(line.content()) {
686                current_diff.push_str("@@");
687                current_diff.push_str(content.trim_start_matches('@'));
688            }
689        } else if (!prefix.is_empty() || line.origin() == ' ')
690            && let Ok(content) = std::str::from_utf8(line.content())
691        {
692            current_diff.push_str(prefix);
693            current_diff.push_str(content);
694        }
695
696        true
697    })?;
698
699    // Don't forget the last file
700    if let Some(file) = current_file
701        && !current_diff.is_empty()
702    {
703        changes.insert(file, ArtifactChange::raw(&current_diff));
704    }
705
706    Ok(changes)
707}
708
709fn short_oid(oid: Oid) -> String {
710    safe_prefix(&oid.to_string(), 8)
711}
712
713/// Return the first `n` characters of a string, safe for any UTF-8 content.
714fn safe_prefix(s: &str, n: usize) -> String {
715    s.chars().take(n).collect()
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    // ── normalize_git_url ──────────────────────────────────────────────
723
724    #[test]
725    fn test_normalize_github_ssh() {
726        assert_eq!(
727            normalize_git_url("git@github.com:org/repo.git"),
728            "github:org/repo"
729        );
730    }
731
732    #[test]
733    fn test_normalize_github_https() {
734        assert_eq!(
735            normalize_git_url("https://github.com/org/repo.git"),
736            "github:org/repo"
737        );
738    }
739
740    #[test]
741    fn test_normalize_github_https_no_suffix() {
742        assert_eq!(
743            normalize_git_url("https://github.com/org/repo"),
744            "github:org/repo"
745        );
746    }
747
748    #[test]
749    fn test_normalize_gitlab_ssh() {
750        assert_eq!(
751            normalize_git_url("git@gitlab.com:org/repo.git"),
752            "gitlab:org/repo"
753        );
754    }
755
756    #[test]
757    fn test_normalize_gitlab_https() {
758        assert_eq!(
759            normalize_git_url("https://gitlab.com/org/repo.git"),
760            "gitlab:org/repo"
761        );
762    }
763
764    #[test]
765    fn test_normalize_unknown_url_passthrough() {
766        let url = "https://bitbucket.org/org/repo.git";
767        assert_eq!(normalize_git_url(url), url);
768    }
769
770    // ── slugify_author ─────────────────────────────────────────────────
771
772    #[test]
773    fn test_slugify_prefers_email_username() {
774        assert_eq!(slugify_author("Alex Smith", "asmith@example.com"), "asmith");
775    }
776
777    #[test]
778    fn test_slugify_falls_back_to_name() {
779        assert_eq!(slugify_author("Alex Smith", "unknown"), "alex-smith");
780    }
781
782    #[test]
783    fn test_slugify_lowercases() {
784        assert_eq!(slugify_author("Alex", "Alex@example.com"), "alex");
785    }
786
787    #[test]
788    fn test_slugify_replaces_special_chars() {
789        assert_eq!(slugify_author("A.B", "a.b@example.com"), "a-b");
790    }
791
792    #[test]
793    fn test_slugify_empty_email_username() {
794        // email with no @ — the split returns the full string, same as email
795        assert_eq!(slugify_author("Test User", "noreply"), "test-user");
796    }
797
798    // ── BranchSpec::parse ──────────────────────────────────────────────
799
800    #[test]
801    fn test_branch_spec_simple() {
802        let spec = BranchSpec::parse("main");
803        assert_eq!(spec.name, "main");
804        assert!(spec.start.is_none());
805    }
806
807    #[test]
808    fn test_branch_spec_with_start() {
809        let spec = BranchSpec::parse("feature:HEAD~5");
810        assert_eq!(spec.name, "feature");
811        assert_eq!(spec.start.as_deref(), Some("HEAD~5"));
812    }
813
814    #[test]
815    fn test_branch_spec_with_commit_start() {
816        let spec = BranchSpec::parse("main:abc1234");
817        assert_eq!(spec.name, "main");
818        assert_eq!(spec.start.as_deref(), Some("abc1234"));
819    }
820
821    // ── safe_prefix / short_oid ────────────────────────────────────────
822
823    #[test]
824    fn test_safe_prefix_ascii() {
825        assert_eq!(safe_prefix("abcdef12345", 8), "abcdef12");
826    }
827
828    #[test]
829    fn test_safe_prefix_short_string() {
830        assert_eq!(safe_prefix("abc", 8), "abc");
831    }
832
833    #[test]
834    fn test_safe_prefix_empty() {
835        assert_eq!(safe_prefix("", 8), "");
836    }
837
838    #[test]
839    fn test_safe_prefix_multibyte() {
840        // Ensure we don't panic on multi-byte chars
841        assert_eq!(safe_prefix("café", 3), "caf");
842        assert_eq!(safe_prefix("日本語テスト", 3), "日本語");
843    }
844
845    #[test]
846    fn test_short_oid() {
847        let oid = Oid::from_str("abcdef1234567890abcdef1234567890abcdef12").unwrap();
848        assert_eq!(short_oid(oid), "abcdef12");
849    }
850
851    // ── DeriveConfig default ───────────────────────────────────────────
852
853    #[test]
854    fn test_derive_config_fields() {
855        let config = DeriveConfig {
856            remote: "origin".to_string(),
857            title: Some("My Graph".to_string()),
858            base: None,
859        };
860        assert_eq!(config.remote, "origin");
861        assert_eq!(config.title.as_deref(), Some("My Graph"));
862        assert!(config.base.is_none());
863    }
864
865    // ── Integration tests with temp git repo ───────────────────────────
866
867    fn init_temp_repo() -> (tempfile::TempDir, Repository) {
868        let dir = tempfile::tempdir().unwrap();
869        let repo = Repository::init(dir.path()).unwrap();
870
871        // Configure author for commits
872        let mut config = repo.config().unwrap();
873        config.set_str("user.name", "Test User").unwrap();
874        config.set_str("user.email", "test@example.com").unwrap();
875
876        (dir, repo)
877    }
878
879    fn create_commit(
880        repo: &Repository,
881        message: &str,
882        file_name: &str,
883        content: &str,
884        parent: Option<&git2::Commit>,
885    ) -> Oid {
886        let mut index = repo.index().unwrap();
887        let file_path = repo.workdir().unwrap().join(file_name);
888        std::fs::write(&file_path, content).unwrap();
889        index.add_path(std::path::Path::new(file_name)).unwrap();
890        index.write().unwrap();
891        let tree_id = index.write_tree().unwrap();
892        let tree = repo.find_tree(tree_id).unwrap();
893        let sig = repo.signature().unwrap();
894        let parents: Vec<&git2::Commit> = parent.into_iter().collect();
895        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
896            .unwrap()
897    }
898
899    #[test]
900    fn test_list_branches_on_repo() {
901        let (_dir, repo) = init_temp_repo();
902        // Create initial commit so a branch exists
903        create_commit(&repo, "initial", "file.txt", "hello", None);
904
905        let branches = list_branches(&repo).unwrap();
906        assert!(!branches.is_empty());
907        // Should contain "main" or "master" depending on git config
908        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
909        assert!(
910            names.contains(&"main") || names.contains(&"master"),
911            "Expected main or master in {:?}",
912            names
913        );
914    }
915
916    #[test]
917    fn test_list_branches_sorted() {
918        let (_dir, repo) = init_temp_repo();
919        let oid = create_commit(&repo, "initial", "file.txt", "hello", None);
920        let commit = repo.find_commit(oid).unwrap();
921
922        // Create additional branches
923        repo.branch("b-beta", &commit, false).unwrap();
924        repo.branch("a-alpha", &commit, false).unwrap();
925
926        let branches = list_branches(&repo).unwrap();
927        let names: Vec<&str> = branches.iter().map(|b| b.name.as_str()).collect();
928        // Should be sorted alphabetically
929        let mut sorted = names.clone();
930        sorted.sort();
931        assert_eq!(names, sorted);
932    }
933
934    #[test]
935    fn test_get_repo_uri_no_remote() {
936        let (_dir, repo) = init_temp_repo();
937        let uri = get_repo_uri(&repo, "origin").unwrap();
938        assert!(
939            uri.starts_with("file://"),
940            "Expected file:// URI, got {}",
941            uri
942        );
943    }
944
945    #[test]
946    fn test_derive_single_branch() {
947        let (_dir, repo) = init_temp_repo();
948        let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
949        let commit1 = repo.find_commit(oid1).unwrap();
950        create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
951
952        let config = DeriveConfig {
953            remote: "origin".to_string(),
954            title: None,
955            base: None,
956        };
957
958        // Get the default branch name
959        let default = find_default_branch(&repo).unwrap_or("main".to_string());
960        let result = derive(&repo, &[default], &config).unwrap();
961
962        match result {
963            Document::Path(path) => {
964                assert!(!path.steps.is_empty(), "Expected at least one step");
965                assert!(path.path.base.is_some());
966            }
967            _ => panic!("Expected Document::Path for single branch"),
968        }
969    }
970
971    #[test]
972    fn test_derive_multiple_branches_produces_graph() {
973        let (_dir, repo) = init_temp_repo();
974        let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
975        let commit1 = repo.find_commit(oid1).unwrap();
976        let _oid2 = create_commit(&repo, "on default", "file.txt", "v2", Some(&commit1));
977
978        let default_branch = find_default_branch(&repo).unwrap();
979
980        // Create a feature branch from commit1
981        repo.branch("feature", &commit1, false).unwrap();
982        repo.set_head("refs/heads/feature").unwrap();
983        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
984            .unwrap();
985        let commit1_again = repo.find_commit(oid1).unwrap();
986        create_commit(
987            &repo,
988            "feature work",
989            "feature.txt",
990            "feat",
991            Some(&commit1_again),
992        );
993
994        // Go back to default branch
995        repo.set_head(&format!("refs/heads/{}", default_branch))
996            .unwrap();
997        repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force()))
998            .unwrap();
999
1000        let config = DeriveConfig {
1001            remote: "origin".to_string(),
1002            title: Some("Test Graph".to_string()),
1003            base: None,
1004        };
1005
1006        let result = derive(&repo, &[default_branch, "feature".to_string()], &config).unwrap();
1007
1008        match result {
1009            Document::Graph(graph) => {
1010                assert_eq!(graph.paths.len(), 2);
1011                assert!(graph.meta.is_some());
1012                assert_eq!(graph.meta.unwrap().title.unwrap(), "Test Graph");
1013            }
1014            _ => panic!("Expected Document::Graph for multiple branches"),
1015        }
1016    }
1017
1018    #[test]
1019    fn test_find_default_branch() {
1020        let (_dir, repo) = init_temp_repo();
1021        create_commit(&repo, "initial", "file.txt", "hello", None);
1022
1023        let default = find_default_branch(&repo);
1024        assert!(default.is_some());
1025        // git init creates "main" or "master" depending on git config
1026        let name = default.unwrap();
1027        assert!(name == "main" || name == "master");
1028    }
1029
1030    #[test]
1031    fn test_branch_info_fields() {
1032        let (_dir, repo) = init_temp_repo();
1033        create_commit(&repo, "test subject line", "file.txt", "hello", None);
1034
1035        let branches = list_branches(&repo).unwrap();
1036        let branch = &branches[0];
1037
1038        assert!(!branch.head.is_empty());
1039        assert_eq!(branch.head_short.len(), 8);
1040        assert_eq!(branch.subject, "test subject line");
1041        assert_eq!(branch.author, "Test User");
1042        assert!(branch.timestamp.ends_with('Z'));
1043    }
1044
1045    #[test]
1046    fn test_derive_with_global_base() {
1047        let (_dir, repo) = init_temp_repo();
1048        let oid1 = create_commit(&repo, "first commit", "file.txt", "v1", None);
1049        let commit1 = repo.find_commit(oid1).unwrap();
1050        let oid2 = create_commit(&repo, "second commit", "file.txt", "v2", Some(&commit1));
1051        let commit2 = repo.find_commit(oid2).unwrap();
1052        create_commit(&repo, "third commit", "file.txt", "v3", Some(&commit2));
1053
1054        let default = find_default_branch(&repo).unwrap();
1055        let config = DeriveConfig {
1056            remote: "origin".to_string(),
1057            title: None,
1058            base: Some(oid1.to_string()),
1059        };
1060
1061        let result = derive(&repo, &[default], &config).unwrap();
1062        match result {
1063            Document::Path(path) => {
1064                // Should only include commits after oid1
1065                assert!(path.steps.len() >= 1);
1066            }
1067            _ => panic!("Expected Document::Path"),
1068        }
1069    }
1070
1071    #[test]
1072    fn test_derive_path_with_branch_start() {
1073        let (_dir, repo) = init_temp_repo();
1074        let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1075        let commit1 = repo.find_commit(oid1).unwrap();
1076        let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1077        let commit2 = repo.find_commit(oid2).unwrap();
1078        create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1079
1080        let default = find_default_branch(&repo).unwrap();
1081        let spec = BranchSpec {
1082            name: default,
1083            start: Some(oid1.to_string()),
1084        };
1085        let config = DeriveConfig {
1086            remote: "origin".to_string(),
1087            title: None,
1088            base: None,
1089        };
1090
1091        let path = derive_path(&repo, &spec, &config).unwrap();
1092        assert!(path.steps.len() >= 1);
1093    }
1094
1095    #[test]
1096    fn test_generate_diff_initial_commit() {
1097        let (_dir, repo) = init_temp_repo();
1098        let oid = create_commit(&repo, "initial", "file.txt", "hello world", None);
1099        let commit = repo.find_commit(oid).unwrap();
1100
1101        let changes = generate_diff(&repo, &commit).unwrap();
1102        // Initial commit should have a diff for the new file
1103        assert!(!changes.is_empty());
1104        assert!(changes.contains_key("file.txt"));
1105    }
1106
1107    #[test]
1108    fn test_collect_commits_range() {
1109        let (_dir, repo) = init_temp_repo();
1110        let oid1 = create_commit(&repo, "first", "file.txt", "v1", None);
1111        let commit1 = repo.find_commit(oid1).unwrap();
1112        let oid2 = create_commit(&repo, "second", "file.txt", "v2", Some(&commit1));
1113        let commit2 = repo.find_commit(oid2).unwrap();
1114        let oid3 = create_commit(&repo, "third", "file.txt", "v3", Some(&commit2));
1115
1116        let commits = collect_commits(&repo, oid1, oid3).unwrap();
1117        assert_eq!(commits.len(), 2); // second and third, not first
1118    }
1119
1120    #[test]
1121    fn test_graph_id_many_branches() {
1122        let (_dir, repo) = init_temp_repo();
1123        let oid1 = create_commit(&repo, "initial", "file.txt", "v1", None);
1124        let commit1 = repo.find_commit(oid1).unwrap();
1125
1126        // Create 4 branches
1127        repo.branch("b1", &commit1, false).unwrap();
1128        repo.branch("b2", &commit1, false).unwrap();
1129        repo.branch("b3", &commit1, false).unwrap();
1130        repo.branch("b4", &commit1, false).unwrap();
1131
1132        let config = DeriveConfig {
1133            remote: "origin".to_string(),
1134            title: None,
1135            base: Some(oid1.to_string()),
1136        };
1137
1138        let result = derive(
1139            &repo,
1140            &[
1141                "b1".to_string(),
1142                "b2".to_string(),
1143                "b3".to_string(),
1144                "b4".to_string(),
1145            ],
1146            &config,
1147        )
1148        .unwrap();
1149
1150        match result {
1151            Document::Graph(g) => {
1152                assert!(g.graph.id.contains("4-branches"));
1153            }
1154            _ => panic!("Expected Graph"),
1155        }
1156    }
1157
1158    #[test]
1159    fn test_commit_to_step_creates_actor() {
1160        let (_dir, repo) = init_temp_repo();
1161        let oid = create_commit(&repo, "a commit", "file.txt", "content", None);
1162        let commit = repo.find_commit(oid).unwrap();
1163
1164        let mut actors = HashMap::new();
1165        let step = commit_to_step(&repo, &commit, Oid::zero(), &mut actors).unwrap();
1166
1167        assert!(step.step.actor.starts_with("human:"));
1168        assert!(!actors.is_empty());
1169        let actor_def = actors.values().next().unwrap();
1170        assert_eq!(actor_def.name.as_deref(), Some("Test User"));
1171    }
1172}