1#![doc = include_str!("../README.md")]
2
3pub struct DeriveConfig {
9 pub remote: String,
11 pub title: Option<String>,
13 pub base: Option<String>,
15}
16
17#[derive(Debug, Clone)]
22pub struct BranchSpec {
23 pub name: String,
24 pub start: Option<String>,
25}
26
27impl BranchSpec {
28 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#[derive(Debug, Clone)]
48pub struct BranchInfo {
49 pub name: String,
51 pub head_short: String,
53 pub head: String,
55 pub subject: String,
57 pub author: String,
59 pub timestamp: String,
61}
62
63pub 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 url.to_string()
112}
113
114pub fn slugify_author(name: &str, email: &str) -> String {
127 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 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#[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 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 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 let base_oid = if let Some(global_base) = &config.base {
201 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 let start_ref = if let Some(rest) = start.strip_prefix("HEAD") {
210 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 find_base_for_branch(repo, &branch_commit)?
225 };
226
227 let base_commit = repo.find_commit(base_oid)?;
228
229 let commits = collect_commits(repo, base_oid, branch_commit.id())?;
231
232 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
234 let steps = generate_steps(repo, &commits, base_oid, &mut actors)?;
235
236 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 pub fn derive_graph(
268 repo: &Repository,
269 branch_specs: &[BranchSpec],
270 config: &DeriveConfig,
271 ) -> Result<Graph> {
272 let default_branch = find_default_branch(repo);
274
275 let default_branch_start =
278 compute_default_branch_start(repo, branch_specs, &default_branch)?;
279
280 let mut paths = Vec::new();
282 for spec in branch_specs {
283 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 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 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 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 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 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(¤t_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(¤t_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#[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 #[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 #[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 #[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}