Skip to main content

toolpath_git/
lib.rs

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