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 },
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 pub fn derive_graph(
267 repo: &Repository,
268 branch_specs: &[BranchSpec],
269 config: &DeriveConfig,
270 ) -> Result<Graph> {
271 let default_branch = find_default_branch(repo);
273
274 let default_branch_start =
277 compute_default_branch_start(repo, branch_specs, &default_branch)?;
278
279 let mut paths = Vec::new();
281 for spec in branch_specs {
282 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 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 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 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 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 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(¤t_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(¤t_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#[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 #[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 #[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 #[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}