1use std::path::Path;
37
38use crate::contract::schema::{Ecosystem, Maturity};
39use crate::ports::{Fs, GitRepo};
40use crate::protocol::facts::{Facts, MaturitySignals, Package};
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
104 let (committers_total, committers_recent_year) = if has_commits {
106 let total = git.shortlog(None).map_or(0, count_lines);
107 let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
108 (total, recent)
109 } else {
110 (0, 0)
111 };
112
113 let tags = if has_commits {
115 git.tags().unwrap_or_default()
116 } else {
117 Vec::new()
118 };
119 let semver_tags: Vec<(u64, u64, u64, bool)> =
120 tags.iter().filter_map(|t| semver_parse(t)).collect();
121 let has_semver_tag = !semver_tags.is_empty();
122 let shipped_release_tags = semver_tags
127 .iter()
128 .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
129 .count();
130 let ge_1_0_tag = semver_tags
131 .iter()
132 .any(|&(major, _, _, pre)| major >= 1 && !pre);
133 let manifest_ge_1_0 = packages
134 .iter()
135 .any(|p| version_ge_1_0(p.version.as_deref()));
136 let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
137
138 let has_ci = CI_GLOBS.iter().any(|glob| {
140 let path = repo_root.join(glob);
141 if !fs.exists(&path) {
142 return false;
143 }
144 if glob.ends_with("workflows") {
145 fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
147 } else {
148 true
149 }
150 });
151
152 let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
154 Some("dependabot".to_string())
155 } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
156 .iter()
157 .any(|f| fs.is_file(&repo_root.join(f)))
158 {
159 Some("renovate".to_string())
160 } else {
161 None
162 };
163
164 let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
165
166 let readme_text = ["README.md", "README.rst", "README.txt", "README"]
168 .iter()
169 .find_map(|name| {
170 let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
171 (!text.is_empty()).then_some(text)
172 });
173 let readme_self_label = readme_text.as_deref().and_then(|text| {
174 let low = text.to_lowercase();
175 SPIKE_LABELS
176 .iter()
177 .any(|label| low.contains(label))
178 .then(|| "spike".to_string())
179 });
180 let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
181
182 let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
205 let release_gate = has_ge_1_0_release || zerover_release_evidence;
206 let production = committers_recent_year >= 2 && has_ci && release_gate;
207 let spike =
208 !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
209 let inferred_maturity = if production {
210 Maturity::Production
211 } else if spike {
212 Maturity::Spike
213 } else {
214 Maturity::Mvp
215 };
216
217 Facts {
218 repo_root: repo_root.display().to_string(),
219 is_git,
220 has_commits,
221 ecosystems,
222 packages,
223 committers_total,
224 committers_recent_year,
225 tags,
226 has_semver_tag,
227 has_ge_1_0_release,
228 has_ci,
229 dependency_bot,
230 has_issues_dir,
231 readme_self_label,
232 description,
233 maturity_signals: MaturitySignals { production, spike },
234 inferred_maturity,
235 }
236}
237
238fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
240 let mut ecosystems: Vec<Ecosystem> = Vec::new();
241 let mut packages: Vec<Package> = Vec::new();
242 for &(fname, eco) in MANIFESTS {
243 let path = repo_root.join(fname);
244 if !fs.is_file(&path) {
245 continue;
246 }
247 let text = read_text(fs, &path, MANIFEST_LIMIT);
248 let parsed = text
249 .as_deref()
250 .and_then(|t| parse_manifest(fname, t))
251 .unwrap_or_default();
252 if !ecosystems.contains(&eco) {
254 ecosystems.push(eco);
255 }
256 if fname == "Cargo.toml" && parsed.package.is_none() {
261 if let Some(text) = text.as_deref() {
262 if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
263 continue;
264 }
265 }
266 }
270 if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
273 packages.push(Package {
274 ecosystem: eco,
275 manifest: fname.to_string(),
276 package: parsed.package,
277 version: parsed.version,
278 });
279 }
280 }
281 if ecosystems.is_empty() {
283 ecosystems.push(Ecosystem::Binary);
284 }
285 (ecosystems, packages)
286}
287
288fn push_workspace_members(
305 repo_root: &Path,
306 fs: &dyn Fs,
307 root_text: &str,
308 eco: Ecosystem,
309 packages: &mut Vec<Package>,
310) -> bool {
311 let ws_block = match toml_section(root_text, "workspace") {
312 Some(block) => block,
313 None => return false,
314 };
315 let members = match toml_str_array(&ws_block, "members") {
316 Some(members) if !members.is_empty() => members,
317 _ => return false,
318 };
319 let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
320 .unwrap_or_default()
321 .iter()
322 .map(|e| e.trim_end_matches('/').to_string())
323 .collect();
324 let ws_pkg = toml_section(root_text, "workspace.package");
325 let before = packages.len();
326 let mut seen: Vec<String> = Vec::new();
328 for member in members {
329 let member = member.trim_end_matches('/');
330 if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
334 continue;
335 }
336 if let Some(prefix) = glob_parent(member) {
337 let dir = if prefix.is_empty() {
339 repo_root.to_path_buf()
340 } else {
341 repo_root.join(prefix)
342 };
343 let mut names = fs.read_dir(&dir).unwrap_or_default();
344 names.sort();
345 for name in names {
346 let rel = if prefix.is_empty() {
347 name
348 } else {
349 format!("{prefix}/{name}")
350 };
351 push_one_member(
352 repo_root,
353 fs,
354 &rel,
355 ws_pkg.as_deref(),
356 eco,
357 &exclude,
358 &mut seen,
359 packages,
360 );
361 }
362 continue;
363 }
364 if member.contains(['*', '?']) {
367 continue;
368 }
369 push_one_member(
370 repo_root,
371 fs,
372 member,
373 ws_pkg.as_deref(),
374 eco,
375 &exclude,
376 &mut seen,
377 packages,
378 );
379 }
380 packages.len() > before
381}
382
383#[allow(clippy::too_many_arguments)]
387fn push_one_member(
388 repo_root: &Path,
389 fs: &dyn Fs,
390 rel: &str,
391 ws_pkg: Option<&str>,
392 eco: Ecosystem,
393 exclude: &[String],
394 seen: &mut Vec<String>,
395 packages: &mut Vec<Package>,
396) {
397 let rel = rel.trim_end_matches('/');
398 if exclude.iter().any(|e| e == rel) || seen.iter().any(|s| s == rel) {
399 return;
400 }
401 let manifest_path = repo_root.join(rel).join("Cargo.toml");
402 if !fs.is_file(&manifest_path) {
403 return;
404 }
405 let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
406 return;
407 };
408 seen.push(rel.to_string());
409 let (package, version) = resolve_member_name_version(&member_text, ws_pkg);
410 packages.push(Package {
411 ecosystem: eco,
412 manifest: format!("{rel}/Cargo.toml"),
413 package,
414 version,
415 });
416}
417
418fn glob_parent(member: &str) -> Option<&str> {
422 if member == "*" {
423 return Some("");
424 }
425 member
426 .strip_suffix("/*")
427 .filter(|prefix| !prefix.contains(['*', '?']))
428}
429
430fn resolve_member_name_version(
434 member_text: &str,
435 ws_pkg: Option<&str>,
436) -> (Option<String>, Option<String>) {
437 let parsed = parse_cargo(member_text).unwrap_or_default();
438 let version = parsed.version.or_else(|| {
439 let inherits = toml_section(member_text, "package")
443 .is_some_and(|block| field_inherits_workspace(&block, "version"));
444 if inherits {
445 ws_pkg.and_then(|block| toml_str_value(block, "version", false))
446 } else {
447 None
448 }
449 });
450 (parsed.package, version)
451}
452
453fn detect_description(
456 repo_root: &Path,
457 fs: &dyn Fs,
458 packages: &[Package],
459 readme_text: Option<&str>,
460) -> Option<String> {
461 let manifest_desc = packages.iter().find_map(|p| {
462 let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
463 let desc = parse_manifest(&p.manifest, &text)?.description?;
464 (!desc.is_empty()).then_some(desc)
465 });
466 if let Some(desc) = manifest_desc {
467 return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
468 }
469 readme_text?.lines().find_map(|line| {
470 let s = line.trim();
471 let is_prose =
472 !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
473 is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
474 })
475}
476
477#[derive(Debug, Default)]
481struct ParsedManifest {
482 package: Option<String>,
483 version: Option<String>,
484 description: Option<String>,
485}
486
487fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
494 let base = Path::new(fname)
495 .file_name()
496 .and_then(|n| n.to_str())
497 .unwrap_or(fname);
498 match base {
499 "Cargo.toml" => parse_cargo(text),
500 "package.json" => parse_package_json(text),
501 "pyproject.toml" => Some(parse_pyproject(text)),
502 "go.mod" => Some(parse_gomod(text)),
503 _ => None, }
505}
506
507fn parse_cargo(text: &str) -> Option<ParsedManifest> {
510 let block = toml_section(text, "package")?;
511 Some(ParsedManifest {
512 package: toml_str_value(&block, "name", false),
513 version: toml_str_value(&block, "version", false),
514 description: toml_str_value(&block, "description", true),
515 })
516}
517
518fn parse_package_json(text: &str) -> Option<ParsedManifest> {
519 let value: serde_json::Value = serde_json::from_str(text).ok()?;
520 let field = |key: &str| {
521 value
522 .get(key)
523 .and_then(serde_json::Value::as_str)
524 .map(str::to_string)
525 };
526 Some(ParsedManifest {
527 package: field("name"),
528 version: field("version"),
529 description: field("description"),
530 })
531}
532
533fn parse_pyproject(text: &str) -> ParsedManifest {
536 let mut parsed = ParsedManifest::default();
537 if let Some(block) = toml_section(text, "project") {
538 parsed.package = toml_str_value(&block, "name", false);
539 parsed.version = toml_str_value(&block, "version", false);
540 parsed.description = toml_str_value(&block, "description", true);
541 }
542 if parsed.package.is_none() {
543 if let Some(block) = toml_section(text, "tool.poetry") {
544 parsed.package = toml_str_value(&block, "name", false);
545 parsed.version = toml_str_value(&block, "version", false);
546 parsed.description = toml_str_value(&block, "description", true);
547 }
548 }
549 parsed
550}
551
552fn parse_gomod(text: &str) -> ParsedManifest {
555 let module = text.lines().find_map(|line| {
556 line.strip_prefix("module")
557 .filter(|rest| rest.starts_with(char::is_whitespace))
558 .and_then(|rest| rest.split_whitespace().next())
559 .map(str::to_string)
560 });
561 ParsedManifest {
562 package: module,
563 version: None,
564 description: None,
565 }
566}
567
568fn toml_section(text: &str, header: &str) -> Option<String> {
572 let needle = format!("[{header}]");
573 let mut in_section = false;
574 let mut out = String::new();
575 for line in text.lines() {
576 if in_section {
577 if line.starts_with('[') {
578 break;
579 }
580 out.push_str(line);
581 out.push('\n');
582 } else if line.starts_with(&needle) {
583 in_section = true;
584 }
585 }
586 in_section.then_some(out)
587}
588
589fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
593 for line in block.lines() {
594 let rest = line.trim_start();
595 let Some(rest) = rest.strip_prefix(key) else {
596 continue;
597 };
598 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
602 continue;
603 }
604 let Some(rest) = rest.trim_start().strip_prefix('=') else {
605 continue;
606 };
607 let Some(value) = extract_quoted(rest.trim_start()) else {
608 continue;
609 };
610 if value.is_empty() && !allow_empty {
611 return None;
612 }
613 return Some(value);
614 }
615 None
616}
617
618fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
625 let mut acc = String::new();
627 let mut collecting = false;
628 for line in block.lines() {
629 let line = strip_toml_comment(line);
632 if collecting {
633 acc.push_str(line);
634 acc.push('\n');
635 if line.contains(']') {
636 break;
637 }
638 continue;
639 }
640 let rest = line.trim_start();
641 let Some(rest) = rest.strip_prefix(key) else {
642 continue;
643 };
644 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
646 continue;
647 }
648 let Some(rest) = rest.trim_start().strip_prefix('=') else {
649 continue;
650 };
651 acc.push_str(rest);
652 acc.push('\n');
653 collecting = true;
654 if rest.contains(']') {
655 break;
656 }
657 }
658 if !collecting {
659 return None;
660 }
661 let start = acc.find('[')?;
663 let end = acc[start..].find(']')? + start;
664 let mut inner = &acc[start + 1..end];
665 let mut out = Vec::new();
666 while let Some(pos) = inner.find(['"', '\'']) {
667 let quote = inner.as_bytes()[pos] as char;
668 let after = &inner[pos + 1..];
669 let Some(close) = after.find(quote) else {
670 break;
671 };
672 out.push(after[..close].to_string());
673 inner = &after[close + 1..];
674 }
675 Some(out)
676}
677
678fn field_inherits_workspace(block: &str, key: &str) -> bool {
684 let dotted = format!("{key}.workspace");
685 for line in block.lines() {
686 let t = strip_toml_comment(line).trim_start();
687 if let Some(rest) = t.strip_prefix(&dotted) {
689 let rest = rest.trim_start();
690 if let Some(rest) = rest.strip_prefix('=') {
691 if is_true_literal(rest.trim_start()) {
692 return true;
693 }
694 }
695 continue;
696 }
697 if let Some(rest) = t.strip_prefix(key) {
699 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
700 continue;
701 }
702 let Some(rest) = rest.trim_start().strip_prefix('=') else {
703 continue;
704 };
705 let v = rest.trim_start();
706 if v.starts_with('{') && inline_table_has_workspace_true(v) {
709 return true;
710 }
711 }
712 }
713 false
714}
715
716fn is_true_literal(s: &str) -> bool {
719 match s.strip_prefix("true") {
720 Some(rest) => {
721 let rest = rest.trim_start();
722 rest.is_empty() || rest.starts_with(['#', '}', ','])
723 }
724 None => false,
725 }
726}
727
728fn inline_table_has_workspace_true(inline: &str) -> bool {
732 let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
733 compact.trim_start_matches('{').split(',').any(|entry| {
734 entry
735 .strip_prefix("workspace=true")
736 .is_some_and(|r| r.is_empty() || r == "}")
737 })
738}
739
740fn strip_toml_comment(line: &str) -> &str {
743 let mut quote: Option<u8> = None;
744 for (i, &b) in line.as_bytes().iter().enumerate() {
745 match quote {
746 Some(q) => {
747 if b == q {
748 quote = None;
749 }
750 }
751 None => match b {
752 b'"' | b'\'' => quote = Some(b),
753 b'#' => return &line[..i],
754 _ => {}
755 },
756 }
757 }
758 line
759}
760
761fn extract_quoted(s: &str) -> Option<String> {
767 let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
768 let s = &s[1..];
769 let end = s.find(quote)?;
770 Some(s[..end].to_string())
771}
772
773fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
781 parse_semver_core(strip_pkg_prefix(tag))
782}
783
784fn strip_pkg_prefix(tag: &str) -> &str {
787 let bytes = tag.as_bytes();
788 for i in (0..bytes.len()).rev() {
789 if matches!(bytes[i], b'@' | b'/' | b'-') {
790 let rest = &tag[i + 1..];
791 if starts_with_version(rest) {
792 return rest;
793 }
794 }
795 }
796 tag
797}
798
799fn starts_with_version(s: &str) -> bool {
801 let mut rest = s.strip_prefix('v').unwrap_or(s);
802 for i in 0..3 {
803 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
804 if digits == 0 {
805 return false;
806 }
807 rest = &rest[digits..];
808 if i < 2 {
809 match rest.strip_prefix('.') {
810 Some(r) => rest = r,
811 None => return false,
812 }
813 }
814 }
815 true
816}
817
818fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
821 let mut rest = core.strip_prefix('v').unwrap_or(core);
822 let mut nums = [0u64; 3];
823 for (i, slot) in nums.iter_mut().enumerate() {
824 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
825 if digits == 0 {
826 return None;
827 }
828 *slot = rest[..digits].parse().ok()?;
829 rest = &rest[digits..];
830 if i < 2 {
831 rest = rest.strip_prefix('.')?;
832 }
833 }
834 let pre = match rest.chars().next() {
835 None | Some('+') => false,
836 Some('-') => true,
837 Some(_) => return None, };
839 Some((nums[0], nums[1], nums[2], pre))
840}
841
842fn version_ge_1_0(version: Option<&str>) -> bool {
844 let Some(v) = version else {
845 return false;
846 };
847 let s = v.strip_prefix('v').unwrap_or(v);
848 let digits = s.bytes().take_while(u8::is_ascii_digit).count();
849 if digits == 0 || !s[digits..].starts_with('.') {
851 return false;
852 }
853 s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
854}
855
856fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
864 let bytes = fs.read(path).ok()?;
865 Some(
866 String::from_utf8_lossy(&bytes)
867 .chars()
868 .take(limit)
869 .collect(),
870 )
871}
872
873fn count_lines(text: String) -> usize {
875 text.lines().filter(|l| !l.trim().is_empty()).count()
876}
877
878fn truncate_chars(s: &str, n: usize) -> String {
880 s.chars().take(n).collect()
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use std::collections::{HashMap, HashSet};
887 use std::path::PathBuf;
888
889 #[derive(Default)]
892 struct FakeFs {
893 files: HashMap<PathBuf, Vec<u8>>,
894 dirs: HashSet<PathBuf>,
895 }
896
897 impl FakeFs {
898 fn file(mut self, path: &str, contents: &str) -> Self {
899 let p = PathBuf::from(path);
900 let mut cur = p.parent();
902 while let Some(dir) = cur {
903 if dir.as_os_str().is_empty() {
904 break;
905 }
906 self.dirs.insert(dir.to_path_buf());
907 cur = dir.parent();
908 }
909 self.files.insert(p, contents.as_bytes().to_vec());
910 self
911 }
912
913 fn dir(mut self, path: &str) -> Self {
914 self.dirs.insert(PathBuf::from(path));
915 self
916 }
917 }
918
919 impl Fs for FakeFs {
920 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
921 self.files
922 .get(path)
923 .cloned()
924 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
925 }
926 fn exists(&self, path: &Path) -> bool {
927 self.files.contains_key(path) || self.dirs.contains(path)
928 }
929 fn is_dir(&self, path: &Path) -> bool {
930 self.dirs.contains(path)
931 }
932 fn is_file(&self, path: &Path) -> bool {
933 self.files.contains_key(path)
934 }
935 fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
936 if !self.dirs.contains(dir) {
937 return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
938 }
939 let mut names: Vec<String> = self
940 .files
941 .keys()
942 .chain(self.dirs.iter())
943 .filter(|p| p.parent() == Some(dir))
944 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
945 .collect();
946 names.sort();
947 Ok(names)
948 }
949 }
950
951 #[derive(Default)]
952 struct FakeGit {
953 work_tree: bool,
954 head: Option<String>,
955 shortlog_all: String,
956 shortlog_recent: String,
957 tags: Vec<String>,
958 }
959
960 impl GitRepo for FakeGit {
961 fn head_commit(&self) -> std::io::Result<String> {
962 self.head
963 .clone()
964 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
965 }
966 fn is_work_tree(&self) -> bool {
967 self.work_tree
968 }
969 fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
970 if !self.work_tree {
971 return Err(std::io::Error::from(std::io::ErrorKind::Other));
972 }
973 Ok(if since.is_some() {
974 self.shortlog_recent.clone()
975 } else {
976 self.shortlog_all.clone()
977 })
978 }
979 fn tags(&self) -> std::io::Result<Vec<String>> {
980 if self.work_tree {
981 Ok(self.tags.clone())
982 } else {
983 Err(std::io::Error::from(std::io::ErrorKind::Other))
984 }
985 }
986 fn git_common_dir(&self) -> std::io::Result<PathBuf> {
987 Ok(PathBuf::from("/repo/.git"))
988 }
989 }
990
991 fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
993 let lines = |n: usize| {
994 (0..n)
995 .map(|i| format!(" 3\tDev {i} <dev{i}@example.com>"))
996 .collect::<Vec<_>>()
997 .join("\n")
998 };
999 FakeGit {
1000 work_tree: true,
1001 head: Some("deadbeef".to_string()),
1002 shortlog_all: lines(total),
1003 shortlog_recent: lines(recent),
1004 tags: tags.iter().map(|t| (*t).to_string()).collect(),
1005 }
1006 }
1007
1008 fn repo() -> &'static Path {
1009 Path::new("/repo")
1010 }
1011
1012 #[test]
1015 fn empty_repo_is_spike_binary() {
1016 let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1017 assert!(!facts.is_git);
1018 assert!(!facts.has_commits);
1019 assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1020 assert!(facts.packages.is_empty());
1021 assert_eq!(facts.committers_total, 0);
1022 assert_eq!(facts.committers_recent_year, 0);
1023 assert!(facts.tags.is_empty());
1024 assert!(!facts.has_ci);
1025 assert_eq!(facts.dependency_bot, None);
1026 assert_eq!(facts.description, None);
1027 assert!(facts.maturity_signals.spike);
1029 assert_eq!(facts.inferred_maturity, Maturity::Spike);
1030 }
1031
1032 #[test]
1033 fn unborn_repo_has_no_commits() {
1034 let git = FakeGit {
1037 work_tree: true,
1038 head: None,
1039 ..FakeGit::default()
1040 };
1041 let facts = gather(repo(), &FakeFs::default(), &git);
1042 assert!(facts.is_git);
1043 assert!(!facts.has_commits);
1044 assert_eq!(facts.committers_total, 0);
1045 assert!(facts.tags.is_empty());
1046 }
1047
1048 #[test]
1051 fn cargo_package_name_version_description() {
1052 let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1053 description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1054 let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1055 let facts = gather(repo(), &fs, &FakeGit::default());
1056 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1057 assert_eq!(facts.packages.len(), 1);
1058 let p = &facts.packages[0];
1059 assert_eq!(p.ecosystem, Ecosystem::Rust);
1060 assert_eq!(p.manifest, "Cargo.toml");
1061 assert_eq!(p.package.as_deref(), Some("rg"));
1062 assert_eq!(p.version.as_deref(), Some("0.3.0"));
1063 assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1064 }
1065
1066 #[test]
1067 fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1068 let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
1072 let facts = gather(repo(), &fs, &FakeGit::default());
1073 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1074 assert_eq!(facts.packages.len(), 1);
1075 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1076 assert_eq!(facts.packages[0].package, None);
1077 assert_eq!(facts.packages[0].version, None);
1078 }
1079
1080 #[test]
1081 fn cargo_virtual_workspace_enumerates_members() {
1082 let root = "[workspace]\nresolver = \"2\"\n\
1085 members = [\"crates/core\", \"crates/cli\"]\n\n\
1086 [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
1087 let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
1088 edition.workspace = true\ndescription = \"the core lib\"\n";
1089 let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
1090 description = \"the cli\"\n";
1091 let fs = FakeFs::default()
1092 .file("/repo/Cargo.toml", root)
1093 .file("/repo/crates/core/Cargo.toml", core)
1094 .file("/repo/crates/cli/Cargo.toml", cli);
1095 let facts = gather(repo(), &fs, &FakeGit::default());
1096 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1097 assert_eq!(facts.packages.len(), 2);
1098 let core_pkg = &facts.packages[0];
1100 assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
1101 assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
1102 assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
1103 assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
1105 let cli_pkg = &facts.packages[1];
1106 assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
1107 assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
1108 assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
1109 assert_eq!(facts.description.as_deref(), Some("the core lib"));
1111 }
1112
1113 #[test]
1114 fn cargo_workspace_inline_version_inheritance() {
1115 let root = "[workspace]\nmembers = [\"m\"]\n\n\
1117 [workspace.package]\nversion = \"1.5.0\"\n";
1118 let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
1119 let fs = FakeFs::default()
1120 .file("/repo/Cargo.toml", root)
1121 .file("/repo/m/Cargo.toml", member);
1122 let facts = gather(repo(), &fs, &FakeGit::default());
1123 assert_eq!(facts.packages.len(), 1);
1124 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1125 assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
1126 assert!(facts.has_ge_1_0_release);
1128 }
1129
1130 #[test]
1131 fn cargo_workspace_multiline_members_array() {
1132 let root = "[workspace]\nmembers = [\n \"a\",\n \"b\",\n]\n\n\
1134 [workspace.package]\nversion = \"0.2.0\"\n";
1135 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1136 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1137 let fs = FakeFs::default()
1138 .file("/repo/Cargo.toml", root)
1139 .file("/repo/a/Cargo.toml", a)
1140 .file("/repo/b/Cargo.toml", b);
1141 let facts = gather(repo(), &fs, &FakeGit::default());
1142 let names: Vec<_> = facts
1143 .packages
1144 .iter()
1145 .map(|p| p.package.as_deref())
1146 .collect();
1147 assert_eq!(names, vec![Some("a"), Some("b")]);
1148 assert!(facts
1149 .packages
1150 .iter()
1151 .all(|p| p.version.as_deref() == Some("0.2.0")));
1152 }
1153
1154 #[test]
1155 fn cargo_workspace_member_without_workspace_package_table() {
1156 let root = "[workspace]\nmembers = [\"m\"]\n";
1159 let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
1160 let fs = FakeFs::default()
1161 .file("/repo/Cargo.toml", root)
1162 .file("/repo/m/Cargo.toml", member);
1163 let facts = gather(repo(), &fs, &FakeGit::default());
1164 assert_eq!(facts.packages.len(), 1);
1165 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1166 assert_eq!(facts.packages[0].version, None);
1167 }
1168
1169 #[test]
1170 fn cargo_workspace_glob_members_are_expanded() {
1171 let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
1174 [workspace.package]\nversion = \"0.4.0\"\n";
1175 let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
1176 let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
1177 let fs = FakeFs::default()
1178 .file("/repo/Cargo.toml", root)
1179 .file("/repo/crates/za/Cargo.toml", a)
1180 .file("/repo/crates/mb/Cargo.toml", b)
1181 .file("/repo/crates/README.md", "not a crate\n");
1182 let facts = gather(repo(), &fs, &FakeGit::default());
1183 let names: Vec<_> = facts
1185 .packages
1186 .iter()
1187 .map(|p| p.package.as_deref())
1188 .collect();
1189 assert_eq!(names, vec![Some("mb"), Some("za")]);
1190 assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
1191 assert!(facts
1192 .packages
1193 .iter()
1194 .all(|p| p.version.as_deref() == Some("0.4.0")));
1195 }
1196
1197 #[test]
1198 fn cargo_workspace_exclude_and_dedup() {
1199 let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
1202 exclude = [\"crates/b\"]\n\n\
1203 [workspace.package]\nversion = \"0.1.0\"\n";
1204 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1205 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1206 let fs = FakeFs::default()
1207 .file("/repo/Cargo.toml", root)
1208 .file("/repo/crates/a/Cargo.toml", a)
1209 .file("/repo/crates/b/Cargo.toml", b);
1210 let facts = gather(repo(), &fs, &FakeGit::default());
1211 let names: Vec<_> = facts
1213 .packages
1214 .iter()
1215 .map(|p| p.package.as_deref())
1216 .collect();
1217 assert_eq!(names, vec![Some("a")]);
1218 }
1219
1220 #[test]
1221 fn cargo_workspace_commented_out_member_is_ignored() {
1222 let root = "[workspace]\nmembers = [\n \"a\",\n # \"b\",\n]\n\n\
1224 [workspace.package]\nversion = \"0.1.0\"\n";
1225 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
1226 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
1227 let fs = FakeFs::default()
1228 .file("/repo/Cargo.toml", root)
1229 .file("/repo/a/Cargo.toml", a)
1230 .file("/repo/b/Cargo.toml", b);
1231 let facts = gather(repo(), &fs, &FakeGit::default());
1232 let names: Vec<_> = facts
1233 .packages
1234 .iter()
1235 .map(|p| p.package.as_deref())
1236 .collect();
1237 assert_eq!(names, vec![Some("a")]);
1238 }
1239
1240 #[test]
1241 fn cargo_workspace_rejects_escaping_member_paths() {
1242 let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
1245 let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
1246 let fs = FakeFs::default()
1247 .file("/repo/Cargo.toml", root)
1248 .file("/outside/Cargo.toml", outside)
1249 .file("/abs/Cargo.toml", outside);
1250 let facts = gather(repo(), &fs, &FakeGit::default());
1251 assert_eq!(facts.packages.len(), 1);
1252 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
1253 assert_eq!(facts.packages[0].package, None);
1254 }
1255
1256 #[test]
1257 fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
1258 let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
1261 [workspace.package]\nversion = \"7.7.7\"\n";
1262 let m = "[package]\nname = \"m\"\n\n\
1263 [package.metadata.tool]\nversion.workspace = true\n";
1264 let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
1265 let fs = FakeFs::default()
1266 .file("/repo/Cargo.toml", root)
1267 .file("/repo/m/Cargo.toml", m)
1268 .file("/repo/n/Cargo.toml", n);
1269 let facts = gather(repo(), &fs, &FakeGit::default());
1270 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
1272 assert_eq!(facts.packages[0].version, None);
1273 assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
1274 assert_eq!(facts.packages[1].version, None);
1275 }
1276
1277 #[test]
1278 fn cargo_workspace_inline_inheritance_rejects_false_positive() {
1279 let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
1282 [workspace.package]\nversion = \"3.0.0\"\n";
1283 let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
1284 let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
1285 let fs = FakeFs::default()
1286 .file("/repo/Cargo.toml", root)
1287 .file("/repo/yes/Cargo.toml", yes)
1288 .file("/repo/no/Cargo.toml", no);
1289 let facts = gather(repo(), &fs, &FakeGit::default());
1290 assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
1291 assert_eq!(facts.packages[1].version, None);
1292 }
1293
1294 #[test]
1295 fn package_json_parsed_and_go_mod_module() {
1296 let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
1297 let fs = FakeFs::default()
1298 .file("/repo/package.json", pkg)
1299 .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
1300 let facts = gather(repo(), &fs, &FakeGit::default());
1301 assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
1302 let node = facts
1303 .packages
1304 .iter()
1305 .find(|p| p.ecosystem == Ecosystem::Node)
1306 .unwrap();
1307 assert_eq!(node.package.as_deref(), Some("@acme/cli"));
1308 assert_eq!(node.version.as_deref(), Some("2.0.0"));
1309 let go = facts
1310 .packages
1311 .iter()
1312 .find(|p| p.ecosystem == Ecosystem::Go)
1313 .unwrap();
1314 assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
1315 assert_eq!(facts.description.as_deref(), Some("cli"));
1317 }
1318
1319 #[test]
1320 fn pyproject_project_then_poetry_fallback() {
1321 let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
1322 description = \"a widget\"\n";
1323 let facts = gather(
1324 repo(),
1325 &FakeFs::default().file("/repo/pyproject.toml", project),
1326 &FakeGit::default(),
1327 );
1328 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
1329 assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
1330
1331 let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
1332 let facts = gather(
1333 repo(),
1334 &FakeFs::default().file("/repo/pyproject.toml", poetry),
1335 &FakeGit::default(),
1336 );
1337 assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
1338 }
1339
1340 #[test]
1341 fn pyproject_single_quoted_strings_parse() {
1342 let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
1345 description = 'a widget'\n";
1346 let facts = gather(
1347 repo(),
1348 &FakeFs::default().file("/repo/pyproject.toml", project),
1349 &FakeGit::default(),
1350 );
1351 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
1352 assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
1353 assert!(facts.has_ge_1_0_release);
1354 assert_eq!(facts.description.as_deref(), Some("a widget"));
1355 }
1356
1357 #[test]
1358 fn toml_key_matches_whole_token_not_prefix() {
1359 let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
1362 name = \"real\"\nversion = \"0.2.0\"\n";
1363 let facts = gather(
1364 repo(),
1365 &FakeFs::default().file("/repo/Cargo.toml", cargo),
1366 &FakeGit::default(),
1367 );
1368 assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
1369 assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
1370 }
1371
1372 #[test]
1373 fn setup_py_marks_python_but_adds_no_package() {
1374 let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
1375 let facts = gather(repo(), &fs, &FakeGit::default());
1376 assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
1377 assert!(facts.packages.is_empty());
1378 }
1379
1380 #[test]
1381 fn ecosystems_emit_in_canonical_order() {
1382 let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
1384 "/repo/Cargo.toml",
1385 "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
1386 );
1387 let facts = gather(repo(), &fs, &FakeGit::default());
1388 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
1389 }
1390
1391 #[test]
1394 fn workflows_dir_counts_only_when_non_empty() {
1395 let empty = FakeFs::default().dir("/repo/.github/workflows").file(
1397 "/repo/Cargo.toml",
1398 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1399 );
1400 assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
1401
1402 let with_wf = FakeFs::default()
1404 .file("/repo/.github/workflows/ci.yml", "on: push\n")
1405 .file(
1406 "/repo/Cargo.toml",
1407 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1408 );
1409 assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
1410 }
1411
1412 #[test]
1413 fn single_file_ci_configs_count_on_existence() {
1414 for name in [
1415 ".gitlab-ci.yml",
1416 "azure-pipelines.yml",
1417 ".drone.yml",
1418 "Jenkinsfile",
1419 ] {
1420 let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
1421 assert!(
1422 gather(repo(), &fs, &FakeGit::default()).has_ci,
1423 "{name} should count as CI"
1424 );
1425 }
1426 }
1427
1428 #[test]
1429 fn dependency_bot_and_issues_dir() {
1430 let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
1431 assert_eq!(
1432 gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
1433 Some("dependabot".to_string())
1434 );
1435 let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
1436 assert_eq!(
1437 gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
1438 Some("renovate".to_string())
1439 );
1440 let both = FakeFs::default()
1442 .file("/repo/.github/dependabot.yml", "version: 2\n")
1443 .file("/repo/renovate.json", "{}\n");
1444 assert_eq!(
1445 gather(repo(), &both, &FakeGit::default()).dependency_bot,
1446 Some("dependabot".to_string())
1447 );
1448 let issues = FakeFs::default().dir("/repo/issues");
1449 assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
1450 }
1451
1452 #[test]
1455 fn readme_self_label_and_prose_description() {
1456 let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
1457 let fs = FakeFs::default().file("/repo/README.md", readme);
1458 let facts = gather(repo(), &fs, &FakeGit::default());
1459 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
1460 assert_eq!(
1462 facts.description.as_deref(),
1463 Some("Status: private, early. Not much yet.")
1464 );
1465 }
1466
1467 #[test]
1468 fn description_truncates_to_120_chars() {
1469 let long = "x".repeat(200);
1470 let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
1471 let facts = gather(repo(), &fs, &FakeGit::default());
1472 let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
1475 let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
1476 let facts2 = gather(repo(), &fs2, &FakeGit::default());
1477 assert_eq!(facts.description.as_deref(), Some("intro"));
1478 assert_eq!(
1480 facts2.description.as_deref().map(|d| d.chars().count()),
1481 Some(120)
1482 );
1483 }
1484
1485 #[test]
1486 fn read_limit_counts_chars_not_bytes() {
1487 let desc = "く".repeat(60); let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
1491 let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
1492 let facts = gather(repo(), &fs, &FakeGit::default());
1493 assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
1494 assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
1496 }
1497
1498 #[test]
1501 fn semver_parse_plain_prefixed_and_prerelease() {
1502 assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
1503 assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
1504 assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
1505 assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
1506 assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
1507 assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
1508 assert_eq!(semver_parse("nightly"), None);
1509 assert_eq!(semver_parse("1.2"), None);
1510 assert_eq!(semver_parse("1.2.3.4"), None);
1511 }
1512
1513 #[test]
1514 fn ge_1_0_release_from_tag_but_not_from_prerelease() {
1515 let fs = FakeFs::default().file(
1516 "/repo/Cargo.toml",
1517 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
1518 );
1519 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
1521 assert!(facts.has_semver_tag);
1522 assert!(facts.has_ge_1_0_release);
1523 let fs2 = FakeFs::default().file(
1525 "/repo/Cargo.toml",
1526 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
1527 );
1528 let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
1529 assert!(facts2.has_semver_tag);
1530 assert!(!facts2.has_ge_1_0_release);
1531 }
1532
1533 #[test]
1534 fn ge_1_0_release_from_manifest_version() {
1535 let fs = FakeFs::default().file(
1536 "/repo/Cargo.toml",
1537 "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
1538 );
1539 let facts = gather(repo(), &fs, &FakeGit::default());
1540 assert!(facts.has_ge_1_0_release);
1541 }
1542
1543 #[test]
1544 fn version_ge_1_0_requires_dot_after_major() {
1545 assert!(version_ge_1_0(Some("1.0.0")));
1546 assert!(version_ge_1_0(Some("v2.3")));
1547 assert!(version_ge_1_0(Some("2024.1")));
1548 assert!(!version_ge_1_0(Some("0.9.9")));
1549 assert!(!version_ge_1_0(Some("1"))); assert!(!version_ge_1_0(None));
1551 }
1552
1553 #[test]
1556 fn production_needs_two_recent_committers_ge_1_0_and_ci() {
1557 let fs = FakeFs::default()
1558 .file(
1559 "/repo/Cargo.toml",
1560 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
1561 )
1562 .file("/repo/.github/workflows/ci.yml", "on: push\n");
1563 let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
1564 assert!(facts.has_ci);
1565 assert!(facts.has_ge_1_0_release);
1566 assert!(facts.maturity_signals.production);
1567 assert_eq!(facts.inferred_maturity, Maturity::Production);
1568 }
1569
1570 #[test]
1571 fn mvp_when_ci_present_but_not_production_grade() {
1572 let fs = FakeFs::default()
1574 .file(
1575 "/repo/Cargo.toml",
1576 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
1577 )
1578 .file("/repo/.github/workflows/ci.yml", "on: push\n");
1579 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
1580 assert!(!facts.maturity_signals.production);
1581 assert!(!facts.maturity_signals.spike);
1582 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1583 }
1584
1585 fn zerover_fs(bot: &str) -> FakeFs {
1588 FakeFs::default()
1589 .file(
1590 "/repo/Cargo.toml",
1591 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1592 )
1593 .file("/repo/.github/workflows/ci.yml", "on: push\n")
1594 .file(&format!("/repo/{bot}"), "version: 2\n")
1595 }
1596
1597 #[test]
1598 fn pre_1_0_with_full_release_infra_is_production() {
1599 let fs = zerover_fs(".github/dependabot.yml");
1605 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
1606 assert!(facts.has_ci);
1607 assert!(!facts.has_ge_1_0_release);
1608 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
1609 assert!(facts.maturity_signals.production);
1610 assert_eq!(facts.inferred_maturity, Maturity::Production);
1611 }
1612
1613 #[test]
1614 fn renovate_unlocks_the_zerover_release_path() {
1615 let fs = zerover_fs("renovate.json");
1618 let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
1619 assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
1620 assert!(facts.maturity_signals.production);
1621 assert_eq!(facts.inferred_maturity, Maturity::Production);
1622 }
1623
1624 #[test]
1625 fn bare_0x_with_only_a_tag_is_not_production() {
1626 let fs = FakeFs::default().file(
1631 "/repo/Cargo.toml",
1632 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1633 );
1634 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
1635 assert!(!facts.has_ci);
1636 assert!(!facts.has_ge_1_0_release);
1637 assert_eq!(facts.dependency_bot, None);
1638 assert!(!facts.maturity_signals.production);
1639 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1641 }
1642
1643 #[test]
1644 fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
1645 let fs = zerover_fs(".github/dependabot.yml");
1650 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
1651 assert!(facts.has_ci);
1652 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
1653 assert!(!facts.maturity_signals.production);
1654 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1655 }
1656
1657 #[test]
1658 fn pre_1_0_release_infra_requires_release_cadence() {
1659 let fs = zerover_fs(".github/dependabot.yml");
1663 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
1664 assert!(!facts.maturity_signals.production);
1665 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1666 }
1667
1668 #[test]
1669 fn pre_1_0_release_infra_requires_dependency_bot() {
1670 let fs = FakeFs::default()
1674 .file(
1675 "/repo/Cargo.toml",
1676 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
1677 )
1678 .file("/repo/.github/workflows/ci.yml", "on: push\n");
1679 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
1680 assert_eq!(facts.dependency_bot, None);
1681 assert!(!facts.maturity_signals.production);
1682 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1683 }
1684
1685 #[test]
1686 fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
1687 let fs = zerover_fs(".github/dependabot.yml");
1692 let facts = gather(
1693 repo(),
1694 &fs,
1695 &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
1696 );
1697 assert!(facts.has_semver_tag);
1698 assert!(!facts.maturity_signals.production);
1699 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1700 }
1701
1702 #[test]
1703 fn pre_1_0_release_infra_requires_two_recent_committers() {
1704 let fs = zerover_fs(".github/dependabot.yml");
1707 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
1708 assert!(!facts.maturity_signals.production);
1709 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1710 }
1711
1712 #[test]
1713 fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
1714 let fs = FakeFs::default()
1718 .file(
1719 "/repo/Cargo.toml",
1720 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
1721 )
1722 .file("/repo/.github/workflows/ci.yml", "on: push\n");
1723 let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
1724 assert!(facts.has_ge_1_0_release);
1725 assert_eq!(facts.dependency_bot, None);
1726 assert!(facts.maturity_signals.production);
1727 assert_eq!(facts.inferred_maturity, Maturity::Production);
1728 }
1729
1730 #[test]
1731 fn spike_forced_by_readme_label_even_with_multiple_committers() {
1732 let fs = FakeFs::default()
1734 .file(
1735 "/repo/Cargo.toml",
1736 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1737 )
1738 .file(
1739 "/repo/README.md",
1740 "# X\n\nThis is an experimental prototype.\n",
1741 );
1742 let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
1743 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
1744 assert!(facts.maturity_signals.spike);
1745 assert_eq!(facts.inferred_maturity, Maturity::Spike);
1746 }
1747
1748 #[test]
1749 fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
1750 let fs = FakeFs::default().file(
1753 "/repo/Cargo.toml",
1754 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
1755 );
1756 let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
1757 assert!(!facts.maturity_signals.spike);
1758 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
1759 }
1760
1761 #[test]
1764 fn same_repo_same_facts() {
1765 let build = || {
1766 FakeFs::default()
1767 .file(
1768 "/repo/Cargo.toml",
1769 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
1770 )
1771 .file("/repo/.github/workflows/ci.yml", "on: push\n")
1772 };
1773 let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
1774 let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
1775 assert_eq!(
1776 serde_json::to_string(&a).unwrap(),
1777 serde_json::to_string(&b).unwrap()
1778 );
1779 }
1780}