1use std::path::Path;
37
38use crate::contract::schema::{Ecosystem, Maturity};
39use crate::ports::{Fs, GitRepo};
40use crate::protocol::facts::{Facts, MaturitySignals, Package, RustWorkspace, WorkspaceMember};
41
42const MANIFESTS: &[(&str, Ecosystem)] = &[
45 ("Cargo.toml", Ecosystem::Rust),
46 ("package.json", Ecosystem::Node),
47 ("pyproject.toml", Ecosystem::Python),
48 ("setup.py", Ecosystem::Python),
49 ("go.mod", Ecosystem::Go),
50];
51
52const SPIKE_LABELS: &[&str] = &[
56 "work in progress",
57 "work-in-progress",
58 "wip",
59 "experimental",
60 "prototype",
61 "pre-alpha",
62 "proof of concept",
63 "proof-of-concept",
64 "status: private, early",
65 "not production ready",
66 "not production-ready",
67 "early prototype",
68 "spike",
69];
70
71const CI_GLOBS: &[&str] = &[
74 ".github/workflows",
75 ".gitlab-ci.yml",
76 ".circleci",
77 "azure-pipelines.yml",
78 ".drone.yml",
79 "Jenkinsfile",
80];
81
82const MANIFEST_LIMIT: usize = 200_000;
85const README_LIMIT: usize = 4_000;
87const DESCRIPTION_CHARS: usize = 120;
89
90#[must_use]
96pub fn gather(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> Facts {
97 let is_git = git.is_work_tree();
98 let has_commits = is_git && git.head_commit().is_ok();
101
102 let (ecosystems, packages) = detect_manifests(repo_root, fs);
103 let rust_workspace = detect_rust_workspace(repo_root, fs);
110
111 let (committers_total, committers_recent_year) = if has_commits {
113 let total = git.shortlog(None).map_or(0, count_lines);
114 let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
115 (total, recent)
116 } else {
117 (0, 0)
118 };
119
120 let tags = if has_commits {
122 git.tags().unwrap_or_default()
123 } else {
124 Vec::new()
125 };
126 let semver_tags: Vec<(u64, u64, u64, bool)> =
127 tags.iter().filter_map(|t| semver_parse(t)).collect();
128 let has_semver_tag = !semver_tags.is_empty();
129 let shipped_release_tags = semver_tags
134 .iter()
135 .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
136 .count();
137 let ge_1_0_tag = semver_tags
138 .iter()
139 .any(|&(major, _, _, pre)| major >= 1 && !pre);
140 let manifest_ge_1_0 = packages
141 .iter()
142 .any(|p| version_ge_1_0(p.version.as_deref()));
143 let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
144
145 let has_ci = CI_GLOBS.iter().any(|glob| {
147 let path = repo_root.join(glob);
148 if !fs.exists(&path) {
149 return false;
150 }
151 if glob.ends_with("workflows") {
152 fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
154 } else {
155 true
156 }
157 });
158
159 let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
161 Some("dependabot".to_string())
162 } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
163 .iter()
164 .any(|f| fs.is_file(&repo_root.join(f)))
165 {
166 Some("renovate".to_string())
167 } else {
168 None
169 };
170
171 let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
172
173 let readme_text = ["README.md", "README.rst", "README.txt", "README"]
175 .iter()
176 .find_map(|name| {
177 let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
178 (!text.is_empty()).then_some(text)
179 });
180 let readme_self_label = readme_text.as_deref().and_then(|text| {
181 let low = text.to_lowercase();
182 SPIKE_LABELS
183 .iter()
184 .any(|label| low.contains(label))
185 .then(|| "spike".to_string())
186 });
187 let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
188
189 let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
212 let release_gate = has_ge_1_0_release || zerover_release_evidence;
213 let production = committers_recent_year >= 2 && has_ci && release_gate;
214 let spike =
215 !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
216 let inferred_maturity = if production {
217 Maturity::Production
218 } else if spike {
219 Maturity::Spike
220 } else {
221 Maturity::Mvp
222 };
223
224 Facts {
225 repo_root: repo_root.display().to_string(),
226 is_git,
227 has_commits,
228 ecosystems,
229 packages,
230 committers_total,
231 committers_recent_year,
232 tags,
233 has_semver_tag,
234 has_ge_1_0_release,
235 has_ci,
236 dependency_bot,
237 has_issues_dir,
238 readme_self_label,
239 description,
240 maturity_signals: MaturitySignals { production, spike },
241 inferred_maturity,
242 rust_workspace,
243 }
244}
245
246fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
248 let mut ecosystems: Vec<Ecosystem> = Vec::new();
249 let mut packages: Vec<Package> = Vec::new();
250 for &(fname, eco) in MANIFESTS {
251 let path = repo_root.join(fname);
252 if !fs.is_file(&path) {
253 continue;
254 }
255 let text = read_text(fs, &path, MANIFEST_LIMIT);
256 let parsed = text
257 .as_deref()
258 .and_then(|t| parse_manifest(fname, t))
259 .unwrap_or_default();
260 if !ecosystems.contains(&eco) {
262 ecosystems.push(eco);
263 }
264 if fname == "Cargo.toml" && parsed.package.is_none() {
269 if let Some(text) = text.as_deref() {
270 if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
271 continue;
272 }
273 }
274 }
278 if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
281 packages.push(Package {
282 ecosystem: eco,
283 manifest: fname.to_string(),
284 package: parsed.package,
285 version: parsed.version,
286 });
287 }
288 }
289 if ecosystems.is_empty() {
291 ecosystems.push(Ecosystem::Binary);
292 }
293 (ecosystems, packages)
294}
295
296fn push_workspace_members(
313 repo_root: &Path,
314 fs: &dyn Fs,
315 root_text: &str,
316 eco: Ecosystem,
317 packages: &mut Vec<Package>,
318) -> bool {
319 let ws_pkg = toml_section(root_text, "workspace.package");
320 let dirs = workspace_member_dirs(repo_root, fs, root_text);
321 let before = packages.len();
322 for rel in &dirs {
323 let manifest_path = repo_root.join(rel).join("Cargo.toml");
324 let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
325 continue;
326 };
327 let (package, version) = resolve_member_name_version(&member_text, ws_pkg.as_deref());
328 packages.push(Package {
329 ecosystem: eco,
330 manifest: format!("{rel}/Cargo.toml"),
331 package,
332 version,
333 });
334 }
335 packages.len() > before
336}
337
338fn workspace_member_dirs(repo_root: &Path, fs: &dyn Fs, root_text: &str) -> Vec<String> {
352 let Some(ws_block) = toml_section(root_text, "workspace") else {
353 return Vec::new();
354 };
355 let members = match toml_str_array(&ws_block, "members") {
356 Some(members) if !members.is_empty() => members,
357 _ => return Vec::new(),
358 };
359 let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
360 .unwrap_or_default()
361 .iter()
362 .map(|e| e.trim_end_matches('/').to_string())
363 .collect();
364 let mut out: Vec<String> = Vec::new();
366 for member in members {
367 let member = member.trim_end_matches('/');
368 if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
369 continue;
370 }
371 if let Some(prefix) = glob_parent(member) {
372 let dir = if prefix.is_empty() {
374 repo_root.to_path_buf()
375 } else {
376 repo_root.join(prefix)
377 };
378 let mut names = fs.read_dir(&dir).unwrap_or_default();
379 names.sort();
380 for name in names {
381 let rel = if prefix.is_empty() {
382 name
383 } else {
384 format!("{prefix}/{name}")
385 };
386 push_member_dir(repo_root, fs, &rel, &exclude, &mut out);
387 }
388 continue;
389 }
390 if member.contains(['*', '?']) {
393 continue;
394 }
395 push_member_dir(repo_root, fs, member, &exclude, &mut out);
396 }
397 out
398}
399
400fn push_member_dir(
403 repo_root: &Path,
404 fs: &dyn Fs,
405 rel: &str,
406 exclude: &[String],
407 out: &mut Vec<String>,
408) {
409 let rel = rel.trim_end_matches('/');
410 if exclude.iter().any(|e| e == rel) || out.iter().any(|s| s == rel) {
411 return;
412 }
413 if !fs.is_file(&repo_root.join(rel).join("Cargo.toml")) {
414 return;
415 }
416 out.push(rel.to_string());
417}
418
419struct RawMember {
450 package: String,
451 version: Option<String>,
452 publishable: bool,
453 deps: Vec<(String, Option<String>)>,
456}
457
458fn detect_rust_workspace(repo_root: &Path, fs: &dyn Fs) -> Option<RustWorkspace> {
459 let root_path = repo_root.join("Cargo.toml");
460 if !fs.is_file(&root_path) {
461 return None;
462 }
463 let root_text = read_text(fs, &root_path, MANIFEST_LIMIT)?;
464 let dirs = workspace_member_dirs(repo_root, fs, &root_text);
465 if dirs.is_empty() {
466 return None;
467 }
468 let ws_pkg = toml_section(&root_text, "workspace.package");
469
470 let mut raw: Vec<RawMember> = Vec::new();
472 for rel in &dirs {
473 let Some(text) = read_text(fs, &repo_root.join(rel).join("Cargo.toml"), MANIFEST_LIMIT)
474 else {
475 continue;
476 };
477 let (package, version) = resolve_member_name_version(&text, ws_pkg.as_deref());
478 let Some(package) = package else { continue };
481 raw.push(RawMember {
482 package,
483 version,
484 publishable: member_publishable_to_crates_io(&text),
485 deps: member_dependency_edges(&text),
486 });
487 }
488
489 let publishable_names: std::collections::BTreeSet<&str> = raw
493 .iter()
494 .filter(|m| m.publishable)
495 .map(|m| m.package.as_str())
496 .collect();
497 let members: Vec<WorkspaceMember> = raw
498 .iter()
499 .filter(|m| m.publishable)
500 .map(|m| {
501 let mut dep_reqs: std::collections::BTreeMap<String, String> =
505 std::collections::BTreeMap::new();
506 for (name, req) in &m.deps {
507 if name.as_str() == m.package || !publishable_names.contains(name.as_str()) {
508 continue;
509 }
510 if let Some(req) = req {
511 dep_reqs.entry(name.clone()).or_insert_with(|| req.clone());
514 }
515 }
516 let mut workspace_deps: Vec<String> = m
517 .deps
518 .iter()
519 .map(|(name, _req)| name.clone())
520 .filter(|d| d.as_str() != m.package && publishable_names.contains(d.as_str()))
521 .collect();
522 workspace_deps.sort();
523 workspace_deps.dedup();
524 WorkspaceMember {
525 package: m.package.clone(),
526 version: m.version.clone(),
527 workspace_deps,
528 dep_reqs,
529 }
530 })
531 .collect();
532 if members.is_empty() {
533 return None;
534 }
535 Some(RustWorkspace { members })
536}
537
538fn member_publishable_to_crates_io(text: &str) -> bool {
546 let Some(block) = toml_section(text, "package") else {
547 return false;
552 };
553 if let Some(regs) = toml_str_array(&block, "publish") {
555 return regs.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS);
556 }
557 !matches!(toml_bool_value(&block, "publish"), Some(false))
559}
560
561const CRATES_IO_REGISTRY_ALIAS: &str = "crates-io";
565
566fn toml_bool_value(block: &str, key: &str) -> Option<bool> {
570 for line in block.lines() {
571 let rest = line.trim_start();
572 let Some(rest) = rest.strip_prefix(key) else {
573 continue;
574 };
575 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
577 continue;
578 }
579 let Some(rest) = rest.trim_start().strip_prefix('=') else {
580 continue;
581 };
582 match strip_toml_comment(rest).trim() {
583 "true" => return Some(true),
584 "false" => return Some(false),
585 _ => return None,
586 }
587 }
588 None
589}
590
591enum DepTable {
593 Table,
598 Sub {
602 key: String,
604 package: Option<String>,
606 local: bool,
608 version: Option<String>,
611 },
612 Other,
614}
615
616fn member_dependency_edges(text: &str) -> Vec<(String, Option<String>)> {
650 let mut state = DepTable::Other;
651 let mut out: Vec<(String, Option<String>)> = Vec::new();
652
653 for line in text.lines() {
654 let t = strip_toml_comment(line).trim();
655 if let Some(header) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
656 flush_dep_subtable(&mut state, &mut out);
658 state = classify_dep_table(header.trim());
659 continue;
660 }
661 match &mut state {
662 DepTable::Table => {
663 if let Some(edge) = dep_edge_from_line(t) {
664 out.push(edge);
665 }
666 }
667 DepTable::Sub {
668 package,
669 local,
670 version,
671 ..
672 } => {
673 if let Some((k, v)) = split_key_value(t) {
677 match k {
678 "package" if package.is_none() => *package = extract_quoted(v),
679 "path" => *local = true,
680 "workspace" if v.trim_start().starts_with("true") => *local = true,
681 "version" if version.is_none() => *version = extract_quoted(v),
682 _ => {}
683 }
684 }
685 }
686 DepTable::Other => {}
687 }
688 }
689 flush_dep_subtable(&mut state, &mut out);
690 out
691}
692
693fn classify_dep_table(header: &str) -> DepTable {
703 if let Some(name) = dep_subtable_name(header) {
704 return DepTable::Sub {
705 key: name.trim().trim_matches(['"', '\'']).to_string(),
706 package: None,
707 local: false,
708 version: None,
709 };
710 }
711 if !header.contains("dev-dependencies")
713 && (header == "dependencies"
714 || header == "build-dependencies"
715 || header.ends_with(".dependencies")
716 || header.ends_with(".build-dependencies"))
717 {
718 return DepTable::Table;
719 }
720 DepTable::Other
721}
722
723fn dep_subtable_name(header: &str) -> Option<&str> {
728 if header.contains("dev-dependencies") {
729 return None;
730 }
731 if let Some(name) = header
732 .strip_prefix("dependencies.")
733 .or_else(|| header.strip_prefix("build-dependencies."))
734 {
735 return Some(name);
736 }
737 for infix in [".dependencies.", ".build-dependencies."] {
740 if let Some(idx) = header.rfind(infix) {
741 return Some(&header[idx + infix.len()..]);
742 }
743 }
744 None
745}
746
747fn dep_edge_from_line(line: &str) -> Option<(String, Option<String>)> {
756 let (key_full, val) = split_key_value(line)?;
757 let mut segs = key_full.split('.');
758 let crate_key = segs.next().unwrap_or("").trim().trim_matches(['"', '\'']);
759 if crate_key.is_empty() {
760 return None;
761 }
762 match segs.next().map(str::trim) {
763 Some("path") => Some((crate_key.to_string(), None)),
769 Some("workspace") if val.trim_start().starts_with("true") => {
770 Some((crate_key.to_string(), None))
771 }
772 Some(_) => None,
775 None => {
777 if !val.starts_with('{') || !inline_table_is_local(val) {
778 return None;
779 }
780 let name = inline_table_package(val).unwrap_or_else(|| crate_key.to_string());
781 Some((name, inline_table_version(val)))
782 }
783 }
784}
785
786fn flush_dep_subtable(state: &mut DepTable, out: &mut Vec<(String, Option<String>)>) {
790 if let DepTable::Sub {
791 key,
792 package,
793 local: true,
794 version,
795 } = state
796 {
797 out.push((
798 package.clone().unwrap_or_else(|| key.clone()),
799 version.clone(),
800 ));
801 }
802 *state = DepTable::Other;
803}
804
805fn split_key_value(line: &str) -> Option<(&str, &str)> {
809 let eq = line.find('=')?;
810 let key = line[..eq].trim().trim_matches(['"', '\'']);
811 if key.is_empty() {
812 return None;
813 }
814 Some((key, line[eq + 1..].trim()))
815}
816
817fn inline_table_is_local(inline: &str) -> bool {
821 contains_toml_key(inline, "path") || toml_key_is_true(inline, "workspace")
822}
823
824fn contains_toml_key(s: &str, key: &str) -> bool {
828 scan_toml_key(s, key, |_after| true)
829}
830
831fn toml_key_is_true(s: &str, key: &str) -> bool {
833 scan_toml_key(s, key, |after| after.trim_start().starts_with("true"))
834}
835
836fn scan_toml_key(s: &str, key: &str, accept: impl Fn(&str) -> bool) -> bool {
841 let mut rest = s;
842 while let Some(pos) = rest.find(key) {
843 let prev_is_ident = rest[..pos]
844 .chars()
845 .next_back()
846 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
847 let after = rest[pos + key.len()..].trim_start();
848 if !prev_is_ident {
849 if let Some(value) = after.strip_prefix('=') {
850 if accept(value) {
851 return true;
852 }
853 }
854 }
855 rest = &rest[pos + key.len()..];
856 }
857 false
858}
859
860fn inline_table_package(inline: &str) -> Option<String> {
868 let mut rest = inline;
869 while let Some(pos) = rest.find("package") {
870 let prev_is_ident = rest[..pos]
871 .chars()
872 .next_back()
873 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
874 let after = rest[pos + "package".len()..].trim_start();
875 if !prev_is_ident {
876 if let Some(value) = after.strip_prefix('=') {
877 return extract_quoted(value.trim_start());
878 }
879 }
880 rest = &rest[pos + "package".len()..];
883 }
884 None
885}
886
887fn inline_table_version(inline: &str) -> Option<String> {
893 let mut rest = inline;
894 while let Some(pos) = rest.find("version") {
895 let prev_is_ident = rest[..pos]
896 .chars()
897 .next_back()
898 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
899 let after = rest[pos + "version".len()..].trim_start();
900 if !prev_is_ident {
901 if let Some(value) = after.strip_prefix('=') {
902 return extract_quoted(value.trim_start());
903 }
904 }
905 rest = &rest[pos + "version".len()..];
906 }
907 None
908}
909
910fn glob_parent(member: &str) -> Option<&str> {
914 if member == "*" {
915 return Some("");
916 }
917 member
918 .strip_suffix("/*")
919 .filter(|prefix| !prefix.contains(['*', '?']))
920}
921
922fn resolve_member_name_version(
926 member_text: &str,
927 ws_pkg: Option<&str>,
928) -> (Option<String>, Option<String>) {
929 let parsed = parse_cargo(member_text).unwrap_or_default();
930 let version = parsed.version.or_else(|| {
931 let inherits = toml_section(member_text, "package")
935 .is_some_and(|block| field_inherits_workspace(&block, "version"));
936 if inherits {
937 ws_pkg.and_then(|block| toml_str_value(block, "version", false))
938 } else {
939 None
940 }
941 });
942 (parsed.package, version)
943}
944
945fn detect_description(
948 repo_root: &Path,
949 fs: &dyn Fs,
950 packages: &[Package],
951 readme_text: Option<&str>,
952) -> Option<String> {
953 let manifest_desc = packages.iter().find_map(|p| {
954 let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
955 let desc = parse_manifest(&p.manifest, &text)?.description?;
956 (!desc.is_empty()).then_some(desc)
957 });
958 if let Some(desc) = manifest_desc {
959 return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
960 }
961 readme_text?.lines().find_map(|line| {
962 let s = line.trim();
963 let is_prose =
964 !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
965 is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
966 })
967}
968
969#[derive(Debug, Default)]
973struct ParsedManifest {
974 package: Option<String>,
975 version: Option<String>,
976 description: Option<String>,
977}
978
979fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
986 let base = Path::new(fname)
987 .file_name()
988 .and_then(|n| n.to_str())
989 .unwrap_or(fname);
990 match base {
991 "Cargo.toml" => parse_cargo(text),
992 "package.json" => parse_package_json(text),
993 "pyproject.toml" => Some(parse_pyproject(text)),
994 "go.mod" => Some(parse_gomod(text)),
995 _ => None, }
997}
998
999fn parse_cargo(text: &str) -> Option<ParsedManifest> {
1002 let block = toml_section(text, "package")?;
1003 Some(ParsedManifest {
1004 package: toml_str_value(&block, "name", false),
1005 version: toml_str_value(&block, "version", false),
1006 description: toml_str_value(&block, "description", true),
1007 })
1008}
1009
1010fn parse_package_json(text: &str) -> Option<ParsedManifest> {
1011 let value: serde_json::Value = serde_json::from_str(text).ok()?;
1012 let field = |key: &str| {
1013 value
1014 .get(key)
1015 .and_then(serde_json::Value::as_str)
1016 .map(str::to_string)
1017 };
1018 Some(ParsedManifest {
1019 package: field("name"),
1020 version: field("version"),
1021 description: field("description"),
1022 })
1023}
1024
1025fn parse_pyproject(text: &str) -> ParsedManifest {
1028 let mut parsed = ParsedManifest::default();
1029 if let Some(block) = toml_section(text, "project") {
1030 parsed.package = toml_str_value(&block, "name", false);
1031 parsed.version = toml_str_value(&block, "version", false);
1032 parsed.description = toml_str_value(&block, "description", true);
1033 }
1034 if parsed.package.is_none() {
1035 if let Some(block) = toml_section(text, "tool.poetry") {
1036 parsed.package = toml_str_value(&block, "name", false);
1037 parsed.version = toml_str_value(&block, "version", false);
1038 parsed.description = toml_str_value(&block, "description", true);
1039 }
1040 }
1041 parsed
1042}
1043
1044fn parse_gomod(text: &str) -> ParsedManifest {
1047 let module = text.lines().find_map(|line| {
1048 line.strip_prefix("module")
1049 .filter(|rest| rest.starts_with(char::is_whitespace))
1050 .and_then(|rest| rest.split_whitespace().next())
1051 .map(str::to_string)
1052 });
1053 ParsedManifest {
1054 package: module,
1055 version: None,
1056 description: None,
1057 }
1058}
1059
1060fn toml_section(text: &str, header: &str) -> Option<String> {
1064 let needle = format!("[{header}]");
1065 let mut in_section = false;
1066 let mut out = String::new();
1067 for line in text.lines() {
1068 if in_section {
1069 if line.starts_with('[') {
1070 break;
1071 }
1072 out.push_str(line);
1073 out.push('\n');
1074 } else if line.starts_with(&needle) {
1075 in_section = true;
1076 }
1077 }
1078 in_section.then_some(out)
1079}
1080
1081fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
1085 for line in block.lines() {
1086 let rest = line.trim_start();
1087 let Some(rest) = rest.strip_prefix(key) else {
1088 continue;
1089 };
1090 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1094 continue;
1095 }
1096 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1097 continue;
1098 };
1099 let Some(value) = extract_quoted(rest.trim_start()) else {
1100 continue;
1101 };
1102 if value.is_empty() && !allow_empty {
1103 return None;
1104 }
1105 return Some(value);
1106 }
1107 None
1108}
1109
1110fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
1117 let mut acc = String::new();
1119 let mut collecting = false;
1120 for line in block.lines() {
1121 let line = strip_toml_comment(line);
1124 if collecting {
1125 acc.push_str(line);
1126 acc.push('\n');
1127 if line.contains(']') {
1128 break;
1129 }
1130 continue;
1131 }
1132 let rest = line.trim_start();
1133 let Some(rest) = rest.strip_prefix(key) else {
1134 continue;
1135 };
1136 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1138 continue;
1139 }
1140 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1141 continue;
1142 };
1143 acc.push_str(rest);
1144 acc.push('\n');
1145 collecting = true;
1146 if rest.contains(']') {
1147 break;
1148 }
1149 }
1150 if !collecting {
1151 return None;
1152 }
1153 let start = acc.find('[')?;
1155 let end = acc[start..].find(']')? + start;
1156 let mut inner = &acc[start + 1..end];
1157 let mut out = Vec::new();
1158 while let Some(pos) = inner.find(['"', '\'']) {
1159 let quote = inner.as_bytes()[pos] as char;
1160 let after = &inner[pos + 1..];
1161 let Some(close) = after.find(quote) else {
1162 break;
1163 };
1164 out.push(after[..close].to_string());
1165 inner = &after[close + 1..];
1166 }
1167 Some(out)
1168}
1169
1170fn field_inherits_workspace(block: &str, key: &str) -> bool {
1176 let dotted = format!("{key}.workspace");
1177 for line in block.lines() {
1178 let t = strip_toml_comment(line).trim_start();
1179 if let Some(rest) = t.strip_prefix(&dotted) {
1181 let rest = rest.trim_start();
1182 if let Some(rest) = rest.strip_prefix('=') {
1183 if is_true_literal(rest.trim_start()) {
1184 return true;
1185 }
1186 }
1187 continue;
1188 }
1189 if let Some(rest) = t.strip_prefix(key) {
1191 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1192 continue;
1193 }
1194 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1195 continue;
1196 };
1197 let v = rest.trim_start();
1198 if v.starts_with('{') && inline_table_has_workspace_true(v) {
1201 return true;
1202 }
1203 }
1204 }
1205 false
1206}
1207
1208fn is_true_literal(s: &str) -> bool {
1211 match s.strip_prefix("true") {
1212 Some(rest) => {
1213 let rest = rest.trim_start();
1214 rest.is_empty() || rest.starts_with(['#', '}', ','])
1215 }
1216 None => false,
1217 }
1218}
1219
1220fn inline_table_has_workspace_true(inline: &str) -> bool {
1224 let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
1225 compact.trim_start_matches('{').split(',').any(|entry| {
1226 entry
1227 .strip_prefix("workspace=true")
1228 .is_some_and(|r| r.is_empty() || r == "}")
1229 })
1230}
1231
1232fn strip_toml_comment(line: &str) -> &str {
1235 let mut quote: Option<u8> = None;
1236 for (i, &b) in line.as_bytes().iter().enumerate() {
1237 match quote {
1238 Some(q) => {
1239 if b == q {
1240 quote = None;
1241 }
1242 }
1243 None => match b {
1244 b'"' | b'\'' => quote = Some(b),
1245 b'#' => return &line[..i],
1246 _ => {}
1247 },
1248 }
1249 }
1250 line
1251}
1252
1253fn extract_quoted(s: &str) -> Option<String> {
1259 let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
1260 let s = &s[1..];
1261 let end = s.find(quote)?;
1262 Some(s[..end].to_string())
1263}
1264
1265fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
1273 parse_semver_core(strip_pkg_prefix(tag))
1274}
1275
1276fn strip_pkg_prefix(tag: &str) -> &str {
1279 let bytes = tag.as_bytes();
1280 for i in (0..bytes.len()).rev() {
1281 if matches!(bytes[i], b'@' | b'/' | b'-') {
1282 let rest = &tag[i + 1..];
1283 if starts_with_version(rest) {
1284 return rest;
1285 }
1286 }
1287 }
1288 tag
1289}
1290
1291fn starts_with_version(s: &str) -> bool {
1293 let mut rest = s.strip_prefix('v').unwrap_or(s);
1294 for i in 0..3 {
1295 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1296 if digits == 0 {
1297 return false;
1298 }
1299 rest = &rest[digits..];
1300 if i < 2 {
1301 match rest.strip_prefix('.') {
1302 Some(r) => rest = r,
1303 None => return false,
1304 }
1305 }
1306 }
1307 true
1308}
1309
1310fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
1313 let mut rest = core.strip_prefix('v').unwrap_or(core);
1314 let mut nums = [0u64; 3];
1315 for (i, slot) in nums.iter_mut().enumerate() {
1316 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1317 if digits == 0 {
1318 return None;
1319 }
1320 *slot = rest[..digits].parse().ok()?;
1321 rest = &rest[digits..];
1322 if i < 2 {
1323 rest = rest.strip_prefix('.')?;
1324 }
1325 }
1326 let pre = match rest.chars().next() {
1327 None | Some('+') => false,
1328 Some('-') => true,
1329 Some(_) => return None, };
1331 Some((nums[0], nums[1], nums[2], pre))
1332}
1333
1334fn version_ge_1_0(version: Option<&str>) -> bool {
1336 let Some(v) = version else {
1337 return false;
1338 };
1339 let s = v.strip_prefix('v').unwrap_or(v);
1340 let digits = s.bytes().take_while(u8::is_ascii_digit).count();
1341 if digits == 0 || !s[digits..].starts_with('.') {
1343 return false;
1344 }
1345 s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
1346}
1347
1348fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
1356 let bytes = fs.read(path).ok()?;
1357 Some(
1358 String::from_utf8_lossy(&bytes)
1359 .chars()
1360 .take(limit)
1361 .collect(),
1362 )
1363}
1364
1365fn count_lines(text: String) -> usize {
1367 text.lines().filter(|l| !l.trim().is_empty()).count()
1368}
1369
1370fn truncate_chars(s: &str, n: usize) -> String {
1372 s.chars().take(n).collect()
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378 use std::collections::{HashMap, HashSet};
1379 use std::path::PathBuf;
1380
1381 #[derive(Default)]
1384 struct FakeFs {
1385 files: HashMap<PathBuf, Vec<u8>>,
1386 dirs: HashSet<PathBuf>,
1387 }
1388
1389 impl FakeFs {
1390 fn file(mut self, path: &str, contents: &str) -> Self {
1391 let p = PathBuf::from(path);
1392 let mut cur = p.parent();
1394 while let Some(dir) = cur {
1395 if dir.as_os_str().is_empty() {
1396 break;
1397 }
1398 self.dirs.insert(dir.to_path_buf());
1399 cur = dir.parent();
1400 }
1401 self.files.insert(p, contents.as_bytes().to_vec());
1402 self
1403 }
1404
1405 fn dir(mut self, path: &str) -> Self {
1406 self.dirs.insert(PathBuf::from(path));
1407 self
1408 }
1409 }
1410
1411 impl Fs for FakeFs {
1412 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
1413 self.files
1414 .get(path)
1415 .cloned()
1416 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1417 }
1418 fn exists(&self, path: &Path) -> bool {
1419 self.files.contains_key(path) || self.dirs.contains(path)
1420 }
1421 fn is_dir(&self, path: &Path) -> bool {
1422 self.dirs.contains(path)
1423 }
1424 fn is_file(&self, path: &Path) -> bool {
1425 self.files.contains_key(path)
1426 }
1427 fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
1428 if !self.dirs.contains(dir) {
1429 return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
1430 }
1431 let mut names: Vec<String> = self
1432 .files
1433 .keys()
1434 .chain(self.dirs.iter())
1435 .filter(|p| p.parent() == Some(dir))
1436 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1437 .collect();
1438 names.sort();
1439 Ok(names)
1440 }
1441 }
1442
1443 #[derive(Default)]
1444 struct FakeGit {
1445 work_tree: bool,
1446 head: Option<String>,
1447 shortlog_all: String,
1448 shortlog_recent: String,
1449 tags: Vec<String>,
1450 }
1451
1452 impl GitRepo for FakeGit {
1453 fn head_commit(&self) -> std::io::Result<String> {
1454 self.head
1455 .clone()
1456 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1457 }
1458 fn is_work_tree(&self) -> bool {
1459 self.work_tree
1460 }
1461 fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
1462 if !self.work_tree {
1463 return Err(std::io::Error::from(std::io::ErrorKind::Other));
1464 }
1465 Ok(if since.is_some() {
1466 self.shortlog_recent.clone()
1467 } else {
1468 self.shortlog_all.clone()
1469 })
1470 }
1471 fn tags(&self) -> std::io::Result<Vec<String>> {
1472 if self.work_tree {
1473 Ok(self.tags.clone())
1474 } else {
1475 Err(std::io::Error::from(std::io::ErrorKind::Other))
1476 }
1477 }
1478 fn git_common_dir(&self) -> std::io::Result<PathBuf> {
1479 Ok(PathBuf::from("/repo/.git"))
1480 }
1481 }
1482
1483 fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
1485 let lines = |n: usize| {
1486 (0..n)
1487 .map(|i| format!(" 3\tDev {i} <dev{i}@example.com>"))
1488 .collect::<Vec<_>>()
1489 .join("\n")
1490 };
1491 FakeGit {
1492 work_tree: true,
1493 head: Some("deadbeef".to_string()),
1494 shortlog_all: lines(total),
1495 shortlog_recent: lines(recent),
1496 tags: tags.iter().map(|t| (*t).to_string()).collect(),
1497 }
1498 }
1499
1500 fn repo() -> &'static Path {
1501 Path::new("/repo")
1502 }
1503
1504 #[test]
1507 fn empty_repo_is_spike_binary() {
1508 let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1509 assert!(!facts.is_git);
1510 assert!(!facts.has_commits);
1511 assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1512 assert!(facts.packages.is_empty());
1513 assert_eq!(facts.committers_total, 0);
1514 assert_eq!(facts.committers_recent_year, 0);
1515 assert!(facts.tags.is_empty());
1516 assert!(!facts.has_ci);
1517 assert_eq!(facts.dependency_bot, None);
1518 assert_eq!(facts.description, None);
1519 assert!(facts.maturity_signals.spike);
1521 assert_eq!(facts.inferred_maturity, Maturity::Spike);
1522 }
1523
1524 #[test]
1525 fn unborn_repo_has_no_commits() {
1526 let git = FakeGit {
1529 work_tree: true,
1530 head: None,
1531 ..FakeGit::default()
1532 };
1533 let facts = gather(repo(), &FakeFs::default(), &git);
1534 assert!(facts.is_git);
1535 assert!(!facts.has_commits);
1536 assert_eq!(facts.committers_total, 0);
1537 assert!(facts.tags.is_empty());
1538 }
1539
1540 #[test]
1543 fn cargo_package_name_version_description() {
1544 let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1545 description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1546 let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1547 let facts = gather(repo(), &fs, &FakeGit::default());
1548 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1549 assert_eq!(facts.packages.len(), 1);
1550 let p = &facts.packages[0];
1551 assert_eq!(p.ecosystem, Ecosystem::Rust);
1552 assert_eq!(p.manifest, "Cargo.toml");
1553 assert_eq!(p.package.as_deref(), Some("rg"));
1554 assert_eq!(p.version.as_deref(), Some("0.3.0"));
1555 assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1556 }
1557
1558 #[test]
1559 fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1560 let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
1564 let facts = gather(repo(), &fs, &FakeGit::default());
1565 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1566 assert_eq!(facts.packages.len(), 1);
1567 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1568 assert_eq!(facts.packages[0].package, None);
1569 assert_eq!(facts.packages[0].version, None);
1570 }
1571
1572 #[test]
1573 fn cargo_virtual_workspace_enumerates_members() {
1574 let root = "[workspace]\nresolver = \"2\"\n\
1577 members = [\"crates/core\", \"crates/cli\"]\n\n\
1578 [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
1579 let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
1580 edition.workspace = true\ndescription = \"the core lib\"\n";
1581 let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
1582 description = \"the cli\"\n";
1583 let fs = FakeFs::default()
1584 .file("/repo/Cargo.toml", root)
1585 .file("/repo/crates/core/Cargo.toml", core)
1586 .file("/repo/crates/cli/Cargo.toml", cli);
1587 let facts = gather(repo(), &fs, &FakeGit::default());
1588 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1589 assert_eq!(facts.packages.len(), 2);
1590 let core_pkg = &facts.packages[0];
1592 assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
1593 assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
1594 assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
1595 assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
1597 let cli_pkg = &facts.packages[1];
1598 assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
1599 assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
1600 assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
1601 assert_eq!(facts.description.as_deref(), Some("the core lib"));
1603 }
1604
1605 #[test]
1608 fn rust_workspace_graph_captures_lib_bin_edge() {
1609 let root = "[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n\n\
1612 [workspace.package]\nversion = \"0.1.6\"\n";
1613 let core = "[package]\nname = \"octl-core\"\nversion.workspace = true\n\
1614 publish = true\n";
1615 let cli = "[package]\nname = \"orchestratectl\"\nversion.workspace = true\n\
1616 publish = true\n\n[dependencies]\n\
1617 octl-core = { path = \"../core\", version = \"=0.1.6\" }\n\
1618 serde = \"1\"\n";
1619 let fs = FakeFs::default()
1620 .file("/repo/Cargo.toml", root)
1621 .file("/repo/crates/core/Cargo.toml", core)
1622 .file("/repo/crates/cli/Cargo.toml", cli);
1623 let ws = detect_rust_workspace(repo(), &fs).expect("a members-bearing workspace");
1624 let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
1626 assert_eq!(names, vec!["octl-core", "orchestratectl"]);
1627 assert!(ws.members[0].workspace_deps.is_empty());
1630 assert_eq!(ws.members[1].workspace_deps, vec!["octl-core".to_string()]);
1631 assert_eq!(ws.members[0].version.as_deref(), Some("0.1.6"));
1632 }
1633
1634 #[test]
1635 fn rust_workspace_graph_drops_unpublishable_members() {
1636 let root = "[workspace]\nmembers = [\"a\", \"b\", \"c\"]\n";
1640 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n"; let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n";
1642 let c = "[package]\nname = \"c\"\nversion = \"1.0.0\"\n\
1643 publish = [\"my-registry\"]\n\n[dependencies]\n\
1644 b = { path = \"../b\" }\n";
1645 let fs = FakeFs::default()
1646 .file("/repo/Cargo.toml", root)
1647 .file("/repo/a/Cargo.toml", a)
1648 .file("/repo/b/Cargo.toml", b)
1649 .file("/repo/c/Cargo.toml", c);
1650 let ws = detect_rust_workspace(repo(), &fs).expect("at least `a` is publishable");
1651 let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
1652 assert_eq!(names, vec!["a"], "only the publishable member survives");
1653 }
1654
1655 #[test]
1656 fn rust_workspace_graph_honors_crates_io_allow_list() {
1657 let root = "[workspace]\nmembers = [\"a\"]\n";
1659 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\
1660 publish = [\"crates-io\"]\n";
1661 let fs = FakeFs::default()
1662 .file("/repo/Cargo.toml", root)
1663 .file("/repo/a/Cargo.toml", a);
1664 let ws = detect_rust_workspace(repo(), &fs).expect("crates-io allow-listed");
1665 assert_eq!(ws.members.len(), 1);
1666 }
1667
1668 #[test]
1669 fn rust_workspace_graph_none_for_single_crate_repo() {
1670 let cargo = "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n";
1673 let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1674 assert!(detect_rust_workspace(repo(), &fs).is_none());
1675 }
1676
1677 #[test]
1678 fn rust_workspace_graph_dep_rename_via_inline_package_key() {
1679 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1682 let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
1683 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1684 alias = { package = \"real-core\", path = \"../core\" }\n";
1685 let fs = FakeFs::default()
1686 .file("/repo/Cargo.toml", root)
1687 .file("/repo/core/Cargo.toml", core)
1688 .file("/repo/cli/Cargo.toml", cli);
1689 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1690 let cli = ws
1691 .members
1692 .iter()
1693 .find(|m| m.package == "cli")
1694 .expect("cli member");
1695 assert_eq!(cli.workspace_deps, vec!["real-core".to_string()]);
1696 }
1697
1698 #[test]
1699 fn rust_workspace_graph_ignores_registry_dep_sharing_a_member_name() {
1700 let root = "[workspace]\nmembers = [\"shared\", \"cli\"]\n";
1705 let shared = "[package]\nname = \"shared\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1706 cli = { path = \"../cli\" }\n"; let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1708 shared = \"1\"\n"; let fs = FakeFs::default()
1710 .file("/repo/Cargo.toml", root)
1711 .file("/repo/shared/Cargo.toml", shared)
1712 .file("/repo/cli/Cargo.toml", cli);
1713 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1714 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1715 let shared = ws.members.iter().find(|m| m.package == "shared").unwrap();
1716 assert!(
1717 cli.workspace_deps.is_empty(),
1718 "a registry dep sharing a member name is not an edge"
1719 );
1720 assert_eq!(
1721 shared.workspace_deps,
1722 vec!["cli".to_string()],
1723 "the real path edge is still detected"
1724 );
1725 }
1726
1727 #[test]
1728 fn rust_workspace_graph_ignores_registry_inline_table_without_path() {
1729 let root = "[workspace]\nmembers = [\"a\", \"b\"]\n";
1731 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n";
1732 let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1733 a = { version = \"1\", features = [\"x\"] }\n";
1734 let fs = FakeFs::default()
1735 .file("/repo/Cargo.toml", root)
1736 .file("/repo/a/Cargo.toml", a)
1737 .file("/repo/b/Cargo.toml", b);
1738 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1739 let b = ws.members.iter().find(|m| m.package == "b").unwrap();
1740 assert!(
1741 b.workspace_deps.is_empty(),
1742 "an inline registry dep (no path/workspace) is not an edge"
1743 );
1744 }
1745
1746 #[test]
1747 fn rust_workspace_graph_records_the_lockstep_pin_requirement() {
1748 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1751 let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
1752 let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
1753 octl-core = { path = \"../core\", version = \"=0.4.0\" }\n";
1754 let fs = FakeFs::default()
1755 .file("/repo/Cargo.toml", root)
1756 .file("/repo/core/Cargo.toml", core)
1757 .file("/repo/cli/Cargo.toml", cli);
1758 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1759 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1760 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1761 assert_eq!(
1762 cli.dep_reqs.get("octl-core").map(String::as_str),
1763 Some("=0.4.0")
1764 );
1765 }
1766
1767 #[test]
1768 fn rust_workspace_graph_omits_req_for_a_pathonly_edge() {
1769 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1773 let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
1774 let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
1775 octl-core = { path = \"../core\" }\n";
1776 let fs = FakeFs::default()
1777 .file("/repo/Cargo.toml", root)
1778 .file("/repo/core/Cargo.toml", core)
1779 .file("/repo/cli/Cargo.toml", cli);
1780 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1781 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1782 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1783 assert!(!cli.dep_reqs.contains_key("octl-core"));
1784 }
1785
1786 #[test]
1787 fn rust_workspace_graph_reads_target_specific_dependency_edges() {
1788 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1791 let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
1792 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
1793 [target.'cfg(unix)'.dependencies]\n\
1794 octl-core = { path = \"../core\", version = \"=1.0.0\" }\n";
1795 let fs = FakeFs::default()
1796 .file("/repo/Cargo.toml", root)
1797 .file("/repo/core/Cargo.toml", core)
1798 .file("/repo/cli/Cargo.toml", cli);
1799 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1800 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1801 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1802 }
1803
1804 #[test]
1805 fn rust_workspace_graph_reads_subtable_path_edge_and_rename() {
1806 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1809 let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
1810 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
1811 [dependencies.alias]\npackage = \"real-core\"\npath = \"../core\"\n\
1812 version = \"=1.0.0\"\n\n\
1813 [dependencies.serde]\nversion = \"1\"\n"; let fs = FakeFs::default()
1815 .file("/repo/Cargo.toml", root)
1816 .file("/repo/core/Cargo.toml", core)
1817 .file("/repo/cli/Cargo.toml", cli);
1818 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1819 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1820 assert_eq!(
1821 cli.workspace_deps,
1822 vec!["real-core".to_string()],
1823 "the renamed path sub-table is the only edge; the version-only one is not"
1824 );
1825 }
1826
1827 #[test]
1828 fn rust_workspace_graph_reads_dotted_key_path_edge() {
1829 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1832 let core = "[package]\nname = \"core\"\nversion = \"1.0.0\"\n";
1833 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1834 core.path = \"../core\"\ncore.version = \"=1.0.0\"\n";
1835 let fs = FakeFs::default()
1836 .file("/repo/Cargo.toml", root)
1837 .file("/repo/core/Cargo.toml", core)
1838 .file("/repo/cli/Cargo.toml", cli);
1839 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1840 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1841 assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
1842 }
1843
1844 #[test]
1845 fn rust_workspace_graph_dep_rename_ignores_package_substring_in_a_path() {
1846 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
1849 let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
1850 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1851 octl-core = { path = \"../my-package-core\", version = \"1\" }\n";
1852 let fs = FakeFs::default()
1853 .file("/repo/Cargo.toml", root)
1854 .file("/repo/core/Cargo.toml", core)
1855 .file("/repo/cli/Cargo.toml", cli);
1856 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1859 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1860 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
1861 }
1862
1863 #[test]
1864 fn rust_workspace_graph_reads_build_dependency_edges() {
1865 let root = "[workspace]\nmembers = [\"gen\", \"app\"]\n";
1867 let gen = "[package]\nname = \"gen\"\nversion = \"1.0.0\"\n";
1868 let app = "[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n\
1869 [build-dependencies]\ngen = { path = \"../gen\" }\n";
1870 let fs = FakeFs::default()
1871 .file("/repo/Cargo.toml", root)
1872 .file("/repo/gen/Cargo.toml", gen)
1873 .file("/repo/app/Cargo.toml", app);
1874 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1875 let app = ws.members.iter().find(|m| m.package == "app").unwrap();
1876 assert_eq!(app.workspace_deps, vec!["gen".to_string()]);
1877 }
1878
1879 #[test]
1880 fn rust_workspace_graph_excludes_dev_dependency_edges() {
1881 let root = "[workspace]\nmembers = [\"lib\", \"cli\"]\n";
1884 let lib = "[package]\nname = \"lib\"\nversion = \"1.0.0\"\n\n\
1885 [dev-dependencies]\ncli = { path = \"../cli\" }\n";
1886 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
1887 lib = { path = \"../lib\" }\n";
1888 let fs = FakeFs::default()
1889 .file("/repo/Cargo.toml", root)
1890 .file("/repo/lib/Cargo.toml", lib)
1891 .file("/repo/cli/Cargo.toml", cli);
1892 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
1893 let lib = ws.members.iter().find(|m| m.package == "lib").unwrap();
1894 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
1895 assert!(
1896 lib.workspace_deps.is_empty(),
1897 "dev-dependency edge is not an ordering edge"
1898 );
1899 assert_eq!(cli.workspace_deps, vec!["lib".to_string()]);
1900 }
1901
1902 #[test]
1903 fn cargo_workspace_inline_version_inheritance() {
1904 let root = "[workspace]\nmembers = [\"m\"]\n\n\
1906 [workspace.package]\nversion = \"1.5.0\"\n";
1907 let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
1908 let fs = FakeFs::default()
1909 .file("/repo/Cargo.toml", root)
1910 .file("/repo/m/Cargo.toml", member);
1911 let facts = gather(repo(), &fs, &FakeGit::default());
1912 assert_eq!(facts.packages.len(), 1);
1913 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1914 assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
1915 assert!(facts.has_ge_1_0_release);
1917 }
1918
1919 #[test]
1920 fn cargo_workspace_multiline_members_array() {
1921 let root = "[workspace]\nmembers = [\n \"a\",\n \"b\",\n]\n\n\
1923 [workspace.package]\nversion = \"0.2.0\"\n";
1924 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1925 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1926 let fs = FakeFs::default()
1927 .file("/repo/Cargo.toml", root)
1928 .file("/repo/a/Cargo.toml", a)
1929 .file("/repo/b/Cargo.toml", b);
1930 let facts = gather(repo(), &fs, &FakeGit::default());
1931 let names: Vec<_> = facts
1932 .packages
1933 .iter()
1934 .map(|p| p.package.as_deref())
1935 .collect();
1936 assert_eq!(names, vec![Some("a"), Some("b")]);
1937 assert!(facts
1938 .packages
1939 .iter()
1940 .all(|p| p.version.as_deref() == Some("0.2.0")));
1941 }
1942
1943 #[test]
1944 fn cargo_workspace_member_without_workspace_package_table() {
1945 let root = "[workspace]\nmembers = [\"m\"]\n";
1948 let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
1949 let fs = FakeFs::default()
1950 .file("/repo/Cargo.toml", root)
1951 .file("/repo/m/Cargo.toml", member);
1952 let facts = gather(repo(), &fs, &FakeGit::default());
1953 assert_eq!(facts.packages.len(), 1);
1954 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1955 assert_eq!(facts.packages[0].version, None);
1956 }
1957
1958 #[test]
1959 fn cargo_workspace_glob_members_are_expanded() {
1960 let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
1963 [workspace.package]\nversion = \"0.4.0\"\n";
1964 let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
1965 let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
1966 let fs = FakeFs::default()
1967 .file("/repo/Cargo.toml", root)
1968 .file("/repo/crates/za/Cargo.toml", a)
1969 .file("/repo/crates/mb/Cargo.toml", b)
1970 .file("/repo/crates/README.md", "not a crate\n");
1971 let facts = gather(repo(), &fs, &FakeGit::default());
1972 let names: Vec<_> = facts
1974 .packages
1975 .iter()
1976 .map(|p| p.package.as_deref())
1977 .collect();
1978 assert_eq!(names, vec![Some("mb"), Some("za")]);
1979 assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
1980 assert!(facts
1981 .packages
1982 .iter()
1983 .all(|p| p.version.as_deref() == Some("0.4.0")));
1984 }
1985
1986 #[test]
1987 fn cargo_workspace_exclude_and_dedup() {
1988 let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
1991 exclude = [\"crates/b\"]\n\n\
1992 [workspace.package]\nversion = \"0.1.0\"\n";
1993 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1994 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1995 let fs = FakeFs::default()
1996 .file("/repo/Cargo.toml", root)
1997 .file("/repo/crates/a/Cargo.toml", a)
1998 .file("/repo/crates/b/Cargo.toml", b);
1999 let facts = gather(repo(), &fs, &FakeGit::default());
2000 let names: Vec<_> = facts
2002 .packages
2003 .iter()
2004 .map(|p| p.package.as_deref())
2005 .collect();
2006 assert_eq!(names, vec![Some("a")]);
2007 }
2008
2009 #[test]
2010 fn cargo_workspace_commented_out_member_is_ignored() {
2011 let root = "[workspace]\nmembers = [\n \"a\",\n # \"b\",\n]\n\n\
2013 [workspace.package]\nversion = \"0.1.0\"\n";
2014 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2015 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2016 let fs = FakeFs::default()
2017 .file("/repo/Cargo.toml", root)
2018 .file("/repo/a/Cargo.toml", a)
2019 .file("/repo/b/Cargo.toml", b);
2020 let facts = gather(repo(), &fs, &FakeGit::default());
2021 let names: Vec<_> = facts
2022 .packages
2023 .iter()
2024 .map(|p| p.package.as_deref())
2025 .collect();
2026 assert_eq!(names, vec![Some("a")]);
2027 }
2028
2029 #[test]
2030 fn cargo_workspace_rejects_escaping_member_paths() {
2031 let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
2034 let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
2035 let fs = FakeFs::default()
2036 .file("/repo/Cargo.toml", root)
2037 .file("/outside/Cargo.toml", outside)
2038 .file("/abs/Cargo.toml", outside);
2039 let facts = gather(repo(), &fs, &FakeGit::default());
2040 assert_eq!(facts.packages.len(), 1);
2041 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2042 assert_eq!(facts.packages[0].package, None);
2043 }
2044
2045 #[test]
2046 fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
2047 let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
2050 [workspace.package]\nversion = \"7.7.7\"\n";
2051 let m = "[package]\nname = \"m\"\n\n\
2052 [package.metadata.tool]\nversion.workspace = true\n";
2053 let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
2054 let fs = FakeFs::default()
2055 .file("/repo/Cargo.toml", root)
2056 .file("/repo/m/Cargo.toml", m)
2057 .file("/repo/n/Cargo.toml", n);
2058 let facts = gather(repo(), &fs, &FakeGit::default());
2059 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2061 assert_eq!(facts.packages[0].version, None);
2062 assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
2063 assert_eq!(facts.packages[1].version, None);
2064 }
2065
2066 #[test]
2067 fn cargo_workspace_inline_inheritance_rejects_false_positive() {
2068 let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
2071 [workspace.package]\nversion = \"3.0.0\"\n";
2072 let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
2073 let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
2074 let fs = FakeFs::default()
2075 .file("/repo/Cargo.toml", root)
2076 .file("/repo/yes/Cargo.toml", yes)
2077 .file("/repo/no/Cargo.toml", no);
2078 let facts = gather(repo(), &fs, &FakeGit::default());
2079 assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
2080 assert_eq!(facts.packages[1].version, None);
2081 }
2082
2083 #[test]
2084 fn package_json_parsed_and_go_mod_module() {
2085 let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
2086 let fs = FakeFs::default()
2087 .file("/repo/package.json", pkg)
2088 .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
2089 let facts = gather(repo(), &fs, &FakeGit::default());
2090 assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
2091 let node = facts
2092 .packages
2093 .iter()
2094 .find(|p| p.ecosystem == Ecosystem::Node)
2095 .unwrap();
2096 assert_eq!(node.package.as_deref(), Some("@acme/cli"));
2097 assert_eq!(node.version.as_deref(), Some("2.0.0"));
2098 let go = facts
2099 .packages
2100 .iter()
2101 .find(|p| p.ecosystem == Ecosystem::Go)
2102 .unwrap();
2103 assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
2104 assert_eq!(facts.description.as_deref(), Some("cli"));
2106 }
2107
2108 #[test]
2109 fn pyproject_project_then_poetry_fallback() {
2110 let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
2111 description = \"a widget\"\n";
2112 let facts = gather(
2113 repo(),
2114 &FakeFs::default().file("/repo/pyproject.toml", project),
2115 &FakeGit::default(),
2116 );
2117 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2118 assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
2119
2120 let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
2121 let facts = gather(
2122 repo(),
2123 &FakeFs::default().file("/repo/pyproject.toml", poetry),
2124 &FakeGit::default(),
2125 );
2126 assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
2127 }
2128
2129 #[test]
2130 fn pyproject_single_quoted_strings_parse() {
2131 let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
2134 description = 'a widget'\n";
2135 let facts = gather(
2136 repo(),
2137 &FakeFs::default().file("/repo/pyproject.toml", project),
2138 &FakeGit::default(),
2139 );
2140 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2141 assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
2142 assert!(facts.has_ge_1_0_release);
2143 assert_eq!(facts.description.as_deref(), Some("a widget"));
2144 }
2145
2146 #[test]
2147 fn toml_key_matches_whole_token_not_prefix() {
2148 let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
2151 name = \"real\"\nversion = \"0.2.0\"\n";
2152 let facts = gather(
2153 repo(),
2154 &FakeFs::default().file("/repo/Cargo.toml", cargo),
2155 &FakeGit::default(),
2156 );
2157 assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
2158 assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
2159 }
2160
2161 #[test]
2162 fn setup_py_marks_python_but_adds_no_package() {
2163 let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
2164 let facts = gather(repo(), &fs, &FakeGit::default());
2165 assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
2166 assert!(facts.packages.is_empty());
2167 }
2168
2169 #[test]
2170 fn ecosystems_emit_in_canonical_order() {
2171 let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
2173 "/repo/Cargo.toml",
2174 "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
2175 );
2176 let facts = gather(repo(), &fs, &FakeGit::default());
2177 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
2178 }
2179
2180 #[test]
2183 fn workflows_dir_counts_only_when_non_empty() {
2184 let empty = FakeFs::default().dir("/repo/.github/workflows").file(
2186 "/repo/Cargo.toml",
2187 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2188 );
2189 assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
2190
2191 let with_wf = FakeFs::default()
2193 .file("/repo/.github/workflows/ci.yml", "on: push\n")
2194 .file(
2195 "/repo/Cargo.toml",
2196 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2197 );
2198 assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
2199 }
2200
2201 #[test]
2202 fn single_file_ci_configs_count_on_existence() {
2203 for name in [
2204 ".gitlab-ci.yml",
2205 "azure-pipelines.yml",
2206 ".drone.yml",
2207 "Jenkinsfile",
2208 ] {
2209 let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
2210 assert!(
2211 gather(repo(), &fs, &FakeGit::default()).has_ci,
2212 "{name} should count as CI"
2213 );
2214 }
2215 }
2216
2217 #[test]
2218 fn dependency_bot_and_issues_dir() {
2219 let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
2220 assert_eq!(
2221 gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
2222 Some("dependabot".to_string())
2223 );
2224 let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
2225 assert_eq!(
2226 gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
2227 Some("renovate".to_string())
2228 );
2229 let both = FakeFs::default()
2231 .file("/repo/.github/dependabot.yml", "version: 2\n")
2232 .file("/repo/renovate.json", "{}\n");
2233 assert_eq!(
2234 gather(repo(), &both, &FakeGit::default()).dependency_bot,
2235 Some("dependabot".to_string())
2236 );
2237 let issues = FakeFs::default().dir("/repo/issues");
2238 assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
2239 }
2240
2241 #[test]
2244 fn readme_self_label_and_prose_description() {
2245 let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
2246 let fs = FakeFs::default().file("/repo/README.md", readme);
2247 let facts = gather(repo(), &fs, &FakeGit::default());
2248 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
2249 assert_eq!(
2251 facts.description.as_deref(),
2252 Some("Status: private, early. Not much yet.")
2253 );
2254 }
2255
2256 #[test]
2257 fn description_truncates_to_120_chars() {
2258 let long = "x".repeat(200);
2259 let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
2260 let facts = gather(repo(), &fs, &FakeGit::default());
2261 let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
2264 let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
2265 let facts2 = gather(repo(), &fs2, &FakeGit::default());
2266 assert_eq!(facts.description.as_deref(), Some("intro"));
2267 assert_eq!(
2269 facts2.description.as_deref().map(|d| d.chars().count()),
2270 Some(120)
2271 );
2272 }
2273
2274 #[test]
2275 fn read_limit_counts_chars_not_bytes() {
2276 let desc = "く".repeat(60); let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
2280 let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
2281 let facts = gather(repo(), &fs, &FakeGit::default());
2282 assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
2283 assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
2285 }
2286
2287 #[test]
2290 fn semver_parse_plain_prefixed_and_prerelease() {
2291 assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
2292 assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
2293 assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
2294 assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
2295 assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
2296 assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
2297 assert_eq!(semver_parse("nightly"), None);
2298 assert_eq!(semver_parse("1.2"), None);
2299 assert_eq!(semver_parse("1.2.3.4"), None);
2300 }
2301
2302 #[test]
2303 fn ge_1_0_release_from_tag_but_not_from_prerelease() {
2304 let fs = FakeFs::default().file(
2305 "/repo/Cargo.toml",
2306 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
2307 );
2308 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
2310 assert!(facts.has_semver_tag);
2311 assert!(facts.has_ge_1_0_release);
2312 let fs2 = FakeFs::default().file(
2314 "/repo/Cargo.toml",
2315 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
2316 );
2317 let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
2318 assert!(facts2.has_semver_tag);
2319 assert!(!facts2.has_ge_1_0_release);
2320 }
2321
2322 #[test]
2323 fn ge_1_0_release_from_manifest_version() {
2324 let fs = FakeFs::default().file(
2325 "/repo/Cargo.toml",
2326 "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
2327 );
2328 let facts = gather(repo(), &fs, &FakeGit::default());
2329 assert!(facts.has_ge_1_0_release);
2330 }
2331
2332 #[test]
2333 fn version_ge_1_0_requires_dot_after_major() {
2334 assert!(version_ge_1_0(Some("1.0.0")));
2335 assert!(version_ge_1_0(Some("v2.3")));
2336 assert!(version_ge_1_0(Some("2024.1")));
2337 assert!(!version_ge_1_0(Some("0.9.9")));
2338 assert!(!version_ge_1_0(Some("1"))); assert!(!version_ge_1_0(None));
2340 }
2341
2342 #[test]
2345 fn production_needs_two_recent_committers_ge_1_0_and_ci() {
2346 let fs = FakeFs::default()
2347 .file(
2348 "/repo/Cargo.toml",
2349 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
2350 )
2351 .file("/repo/.github/workflows/ci.yml", "on: push\n");
2352 let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
2353 assert!(facts.has_ci);
2354 assert!(facts.has_ge_1_0_release);
2355 assert!(facts.maturity_signals.production);
2356 assert_eq!(facts.inferred_maturity, Maturity::Production);
2357 }
2358
2359 #[test]
2360 fn mvp_when_ci_present_but_not_production_grade() {
2361 let fs = FakeFs::default()
2363 .file(
2364 "/repo/Cargo.toml",
2365 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
2366 )
2367 .file("/repo/.github/workflows/ci.yml", "on: push\n");
2368 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
2369 assert!(!facts.maturity_signals.production);
2370 assert!(!facts.maturity_signals.spike);
2371 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2372 }
2373
2374 fn zerover_fs(bot: &str) -> FakeFs {
2377 FakeFs::default()
2378 .file(
2379 "/repo/Cargo.toml",
2380 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2381 )
2382 .file("/repo/.github/workflows/ci.yml", "on: push\n")
2383 .file(&format!("/repo/{bot}"), "version: 2\n")
2384 }
2385
2386 #[test]
2387 fn pre_1_0_with_full_release_infra_is_production() {
2388 let fs = zerover_fs(".github/dependabot.yml");
2394 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
2395 assert!(facts.has_ci);
2396 assert!(!facts.has_ge_1_0_release);
2397 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
2398 assert!(facts.maturity_signals.production);
2399 assert_eq!(facts.inferred_maturity, Maturity::Production);
2400 }
2401
2402 #[test]
2403 fn renovate_unlocks_the_zerover_release_path() {
2404 let fs = zerover_fs("renovate.json");
2407 let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
2408 assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
2409 assert!(facts.maturity_signals.production);
2410 assert_eq!(facts.inferred_maturity, Maturity::Production);
2411 }
2412
2413 #[test]
2414 fn bare_0x_with_only_a_tag_is_not_production() {
2415 let fs = FakeFs::default().file(
2420 "/repo/Cargo.toml",
2421 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2422 );
2423 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
2424 assert!(!facts.has_ci);
2425 assert!(!facts.has_ge_1_0_release);
2426 assert_eq!(facts.dependency_bot, None);
2427 assert!(!facts.maturity_signals.production);
2428 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2430 }
2431
2432 #[test]
2433 fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
2434 let fs = zerover_fs(".github/dependabot.yml");
2439 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
2440 assert!(facts.has_ci);
2441 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
2442 assert!(!facts.maturity_signals.production);
2443 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2444 }
2445
2446 #[test]
2447 fn pre_1_0_release_infra_requires_release_cadence() {
2448 let fs = zerover_fs(".github/dependabot.yml");
2452 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
2453 assert!(!facts.maturity_signals.production);
2454 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2455 }
2456
2457 #[test]
2458 fn pre_1_0_release_infra_requires_dependency_bot() {
2459 let fs = FakeFs::default()
2463 .file(
2464 "/repo/Cargo.toml",
2465 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
2466 )
2467 .file("/repo/.github/workflows/ci.yml", "on: push\n");
2468 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
2469 assert_eq!(facts.dependency_bot, None);
2470 assert!(!facts.maturity_signals.production);
2471 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2472 }
2473
2474 #[test]
2475 fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
2476 let fs = zerover_fs(".github/dependabot.yml");
2481 let facts = gather(
2482 repo(),
2483 &fs,
2484 &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
2485 );
2486 assert!(facts.has_semver_tag);
2487 assert!(!facts.maturity_signals.production);
2488 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2489 }
2490
2491 #[test]
2492 fn pre_1_0_release_infra_requires_two_recent_committers() {
2493 let fs = zerover_fs(".github/dependabot.yml");
2496 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
2497 assert!(!facts.maturity_signals.production);
2498 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2499 }
2500
2501 #[test]
2502 fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
2503 let fs = FakeFs::default()
2507 .file(
2508 "/repo/Cargo.toml",
2509 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
2510 )
2511 .file("/repo/.github/workflows/ci.yml", "on: push\n");
2512 let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
2513 assert!(facts.has_ge_1_0_release);
2514 assert_eq!(facts.dependency_bot, None);
2515 assert!(facts.maturity_signals.production);
2516 assert_eq!(facts.inferred_maturity, Maturity::Production);
2517 }
2518
2519 #[test]
2520 fn spike_forced_by_readme_label_even_with_multiple_committers() {
2521 let fs = FakeFs::default()
2523 .file(
2524 "/repo/Cargo.toml",
2525 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2526 )
2527 .file(
2528 "/repo/README.md",
2529 "# X\n\nThis is an experimental prototype.\n",
2530 );
2531 let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
2532 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
2533 assert!(facts.maturity_signals.spike);
2534 assert_eq!(facts.inferred_maturity, Maturity::Spike);
2535 }
2536
2537 #[test]
2538 fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
2539 let fs = FakeFs::default().file(
2542 "/repo/Cargo.toml",
2543 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2544 );
2545 let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
2546 assert!(!facts.maturity_signals.spike);
2547 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
2548 }
2549
2550 #[test]
2553 fn same_repo_same_facts() {
2554 let build = || {
2555 FakeFs::default()
2556 .file(
2557 "/repo/Cargo.toml",
2558 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
2559 )
2560 .file("/repo/.github/workflows/ci.yml", "on: push\n")
2561 };
2562 let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
2563 let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
2564 assert_eq!(
2565 serde_json::to_string(&a).unwrap(),
2566 serde_json::to_string(&b).unwrap()
2567 );
2568 }
2569}