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