Skip to main content

toolpath_git/
lib.rs

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