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