1use std::path::Path;
37
38use crate::contract::schema::{Ecosystem, Maturity};
39use crate::ports::{Fs, GitRepo};
40pub use crate::protocol::facts::{CargoPublishFlag, CargoPublishPolicy};
41use crate::protocol::facts::{
42 DistributionSurface, Facts, FactsReport, MaturitySignals, Package, RustWorkspace,
43 WorkspaceMember,
44};
45
46const MANIFESTS: &[(&str, Ecosystem)] = &[
49 ("Cargo.toml", Ecosystem::Rust),
50 ("package.json", Ecosystem::Node),
51 ("pyproject.toml", Ecosystem::Python),
52 ("setup.py", Ecosystem::Python),
53 ("go.mod", Ecosystem::Go),
54];
55
56const SPIKE_LABELS: &[&str] = &[
60 "work in progress",
61 "work-in-progress",
62 "wip",
63 "experimental",
64 "prototype",
65 "pre-alpha",
66 "proof of concept",
67 "proof-of-concept",
68 "status: private, early",
69 "not production ready",
70 "not production-ready",
71 "early prototype",
72 "spike",
73];
74
75const CI_GLOBS: &[&str] = &[
78 ".github/workflows",
79 ".gitlab-ci.yml",
80 ".circleci",
81 "azure-pipelines.yml",
82 ".drone.yml",
83 "Jenkinsfile",
84];
85
86const MANIFEST_LIMIT: usize = 200_000;
89const README_LIMIT: usize = 4_000;
91const DESCRIPTION_CHARS: usize = 120;
93
94#[must_use]
100pub fn gather(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> Facts {
101 let is_git = git.is_work_tree();
102 let has_commits = is_git && git.head_commit().is_ok();
105
106 let (ecosystems, packages) = detect_manifests(repo_root, fs);
107 let rust_workspace = detect_rust_workspace(repo_root, fs);
114 let distribution_surface = detect_distribution_surface(repo_root, fs);
115
116 let (committers_total, committers_recent_year) = if has_commits {
118 let total = git.shortlog(None).map_or(0, count_lines);
119 let recent = git.shortlog(Some("1 year ago")).map_or(0, count_lines);
120 (total, recent)
121 } else {
122 (0, 0)
123 };
124
125 let tags = if has_commits {
127 git.tags().unwrap_or_default()
128 } else {
129 Vec::new()
130 };
131 let semver_tags: Vec<(u64, u64, u64, bool)> =
132 tags.iter().filter_map(|t| semver_parse(t)).collect();
133 let has_semver_tag = !semver_tags.is_empty();
134 let shipped_release_tags = semver_tags
139 .iter()
140 .filter(|&&(major, minor, _, pre)| !pre && (major >= 1 || minor >= 1))
141 .count();
142 let ge_1_0_tag = semver_tags
143 .iter()
144 .any(|&(major, _, _, pre)| major >= 1 && !pre);
145 let manifest_ge_1_0 = packages
146 .iter()
147 .any(|p| version_ge_1_0(p.version.as_deref()));
148 let has_ge_1_0_release = ge_1_0_tag || manifest_ge_1_0;
149
150 let has_ci = CI_GLOBS.iter().any(|glob| {
152 let path = repo_root.join(glob);
153 if !fs.exists(&path) {
154 return false;
155 }
156 if glob.ends_with("workflows") {
157 fs.read_dir(&path).is_ok_and(|e| !e.is_empty())
159 } else {
160 true
161 }
162 });
163
164 let dependency_bot = if fs.is_file(&repo_root.join(".github/dependabot.yml")) {
166 Some("dependabot".to_string())
167 } else if ["renovate.json", ".renovaterc", ".renovaterc.json"]
168 .iter()
169 .any(|f| fs.is_file(&repo_root.join(f)))
170 {
171 Some("renovate".to_string())
172 } else {
173 None
174 };
175
176 let has_issues_dir = fs.is_dir(&repo_root.join("issues"));
177
178 let readme_text = ["README.md", "README.rst", "README.txt", "README"]
180 .iter()
181 .find_map(|name| {
182 let text = read_text(fs, &repo_root.join(name), README_LIMIT)?;
183 (!text.is_empty()).then_some(text)
184 });
185 let readme_self_label = readme_text.as_deref().and_then(|text| {
186 let low = text.to_lowercase();
187 SPIKE_LABELS
188 .iter()
189 .any(|label| low.contains(label))
190 .then(|| "spike".to_string())
191 });
192 let description = detect_description(repo_root, fs, &packages, readme_text.as_deref());
193
194 let zerover_release_evidence = dependency_bot.is_some() && shipped_release_tags >= 2;
217 let release_gate = has_ge_1_0_release || zerover_release_evidence;
218 let production = committers_recent_year >= 2 && has_ci && release_gate;
219 let spike =
220 !has_ci && !has_semver_tag && (committers_total <= 1 || readme_self_label.is_some());
221 let inferred_maturity = if production {
222 Maturity::Production
223 } else if spike {
224 Maturity::Spike
225 } else {
226 Maturity::Mvp
227 };
228
229 Facts {
230 repo_root: repo_root.display().to_string(),
231 is_git,
232 has_commits,
233 ecosystems,
234 packages,
235 committers_total,
236 committers_recent_year,
237 tags,
238 has_semver_tag,
239 has_ge_1_0_release,
240 has_ci,
241 dependency_bot,
242 has_issues_dir,
243 readme_self_label,
244 description,
245 maturity_signals: MaturitySignals { production, spike },
246 inferred_maturity,
247 distribution_surface,
248 rust_workspace,
249 }
250}
251
252#[must_use]
259pub fn gather_report(repo_root: &Path, fs: &dyn Fs, git: &dyn GitRepo) -> FactsReport {
260 FactsReport {
261 facts: gather(repo_root, fs, git),
262 cargo_publish: cargo_publish_evidence(repo_root, fs),
263 }
264}
265
266pub fn detect_distribution_surface(repo_root: &Path, fs: &dyn Fs) -> DistributionSurface {
276 let mut cargo_dist_evidence = Vec::new();
277 if fs.is_file(&repo_root.join("dist-workspace.toml")) {
278 cargo_dist_evidence.push("dist-workspace.toml".to_string());
279 }
280 if read_text(fs, &repo_root.join("Cargo.toml"), MANIFEST_LIMIT).is_some_and(|text| {
281 text.lines()
282 .any(|line| line.trim() == "[workspace.metadata.dist]")
283 }) {
284 cargo_dist_evidence.push("Cargo.toml ([workspace.metadata.dist])".to_string());
285 }
286
287 let workflows = repo_root.join(".github/workflows");
288 let mut workflow_texts = fs
289 .read_dir(&workflows)
290 .unwrap_or_default()
291 .into_iter()
292 .filter(|name| {
293 Path::new(name).extension().is_some_and(|extension| {
294 extension.eq_ignore_ascii_case("yml") || extension.eq_ignore_ascii_case("yaml")
295 })
296 })
297 .filter_map(|name| {
298 read_text(fs, &workflows.join(&name), MANIFEST_LIMIT).map(|text| (name, text))
299 })
300 .collect::<Vec<_>>();
301 workflow_texts.sort_by(|a, b| a.0.cmp(&b.0));
302
303 let tag_triggered_workflows = workflow_texts
304 .iter()
305 .filter(|(_, text)| workflow_pushes_tags(text))
306 .map(|(name, _)| name.clone())
307 .collect::<Vec<_>>();
308 let tag_triggered_cargo_publish_workflows = workflow_texts
309 .iter()
310 .filter(|(_, text)| workflow_pushes_tags(text))
311 .filter(|(name, _)| workflow_reaches_cargo_publish(name, &workflow_texts, &mut Vec::new()))
312 .map(|(name, _)| name.clone())
313 .collect::<Vec<_>>();
314
315 DistributionSurface {
316 has_cargo_dist: !cargo_dist_evidence.is_empty(),
317 cargo_dist_evidence,
318 tag_triggered_workflows,
319 tag_triggered_cargo_publish_workflows,
320 }
321}
322
323fn workflow_reaches_cargo_publish(
324 name: &str,
325 workflows: &[(String, String)],
326 visiting: &mut Vec<String>,
327) -> bool {
328 if visiting.iter().any(|seen| seen == name) {
329 return false;
330 }
331 let Some((_, text)) = workflows.iter().find(|(candidate, _)| candidate == name) else {
332 return false;
333 };
334 if workflow_runs_cargo_publish(text) {
335 return true;
336 }
337
338 visiting.push(name.to_string());
339 let reaches_publish = local_workflow_calls(text).iter().any(|called| {
340 workflows
341 .iter()
342 .find(|(candidate, _)| candidate == called)
343 .is_some_and(|(_, callee)| {
344 workflow_accepts_calls(callee)
345 && workflow_reaches_cargo_publish(called, workflows, visiting)
346 })
347 });
348 visiting.pop();
349 reaches_publish
350}
351
352fn workflow_runs_cargo_publish(text: &str) -> bool {
353 workflow_jobs(text).is_some_and(|jobs| {
354 jobs.values().any(|job| {
355 yaml_mapping(job)
356 .and_then(|job| job.get("steps"))
357 .and_then(serde_yaml::Value::as_sequence)
358 .is_some_and(|steps| {
359 steps.iter().any(|step| {
360 yaml_mapping(step)
361 .and_then(|step| step.get("run"))
362 .and_then(serde_yaml::Value::as_str)
363 .is_some_and(command_runs_cargo_publish)
364 })
365 })
366 })
367 })
368}
369
370fn command_runs_cargo_publish(command: &str) -> bool {
371 command.lines().any(|line| {
372 let uncommented = line.split('#').next().unwrap_or("");
373 uncommented
374 .split([';', '\n'])
375 .flat_map(|part| part.split("&&"))
376 .flat_map(|part| part.split("||"))
377 .any(command_segment_runs_cargo_publish)
378 })
379}
380
381fn command_segment_runs_cargo_publish(segment: &str) -> bool {
382 let tokens = segment.split_ascii_whitespace().collect::<Vec<_>>();
383 let mut index = tokens
384 .iter()
385 .position(|token| *token == "cargo")
386 .unwrap_or(tokens.len());
387 if index == tokens.len()
388 || tokens[..index]
389 .iter()
390 .any(|token| !token.contains('=') || token.starts_with('-'))
391 {
392 return false;
393 }
394 index += 1;
395 if tokens
396 .get(index)
397 .is_some_and(|token| token.starts_with('+'))
398 {
399 index += 1;
400 }
401 if tokens.get(index) != Some(&"publish") {
402 return false;
403 }
404
405 let options = &tokens[index + 1..];
406 if options.iter().any(|option| {
407 matches!(*option, "--dry-run" | "--help" | "-h" | "--index")
408 || option.starts_with("--index=")
409 }) {
410 return false;
411 }
412 for (option_index, option) in options.iter().enumerate() {
413 if let Some(registry) = option.strip_prefix("--registry=") {
414 if registry != "crates-io" {
415 return false;
416 }
417 } else if *option == "--registry"
418 && options.get(option_index + 1).copied() != Some("crates-io")
419 {
420 return false;
421 }
422 }
423 true
424}
425
426fn workflow_accepts_calls(text: &str) -> bool {
427 let mut on_indent = None;
428 for line in text.lines() {
429 let uncommented = line.split('#').next().unwrap_or("");
430 if uncommented.trim().is_empty() {
431 continue;
432 }
433 let indent = uncommented.len() - uncommented.trim_start().len();
434 let key = uncommented.trim();
435 if key == "on:" {
436 on_indent = Some(indent);
437 continue;
438 }
439 if let Some(on) = on_indent {
440 if indent <= on {
441 return false;
442 }
443 if key == "workflow_call:" {
444 return true;
445 }
446 }
447 }
448 false
449}
450
451fn local_workflow_calls(text: &str) -> Vec<String> {
452 let Some(jobs) = workflow_jobs(text) else {
453 return Vec::new();
454 };
455 jobs.into_values()
456 .filter_map(|job| {
457 job.as_mapping()
458 .and_then(|job| job.get("uses"))
459 .and_then(serde_yaml::Value::as_str)
460 .and_then(|uses| uses.strip_prefix("./.github/workflows/"))
461 .map(str::to_string)
462 })
463 .collect()
464}
465
466fn workflow_jobs(text: &str) -> Option<serde_yaml::Mapping> {
467 let document = serde_yaml::from_str::<serde_yaml::Value>(text).ok()?;
468 yaml_mapping(&document)
469 .and_then(|root| root.get("jobs"))
470 .and_then(serde_yaml::Value::as_mapping)
471 .cloned()
472}
473
474fn yaml_mapping(value: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> {
475 value.as_mapping()
476}
477
478fn workflow_pushes_tags(text: &str) -> bool {
479 let mut on_indent = None;
480 let mut push_indent = None;
481 for line in text.lines() {
482 let uncommented = line.split('#').next().unwrap_or("");
483 if uncommented.trim().is_empty() {
484 continue;
485 }
486 let indent = uncommented.len() - uncommented.trim_start().len();
487 let key = uncommented.trim();
488 if key == "on:" {
489 on_indent = Some(indent);
490 push_indent = None;
491 continue;
492 }
493 if let Some(on) = on_indent {
494 if indent <= on {
495 on_indent = None;
496 push_indent = None;
497 continue;
498 }
499 if key == "push:" {
500 push_indent = Some(indent);
501 continue;
502 }
503 }
504 if let Some(push) = push_indent {
505 if indent <= push {
506 push_indent = None;
507 } else if key == "tags:" {
508 return true;
509 }
510 }
511 }
512 false
513}
514
515#[must_use]
533pub fn cargo_publish_evidence(repo_root: &Path, fs: &dyn Fs) -> Vec<CargoPublishFlag> {
534 let root_path = repo_root.join("Cargo.toml");
535 let Some(root_text) = read_text(fs, &root_path, MANIFEST_LIMIT) else {
536 return Vec::new();
537 };
538 let ws_pkg = toml_section(&root_text, "workspace.package");
539 let inherited = ws_pkg
542 .as_deref()
543 .map_or(CargoPublishPolicy::Unknown, |block| {
544 block_publish_policy(block)
545 });
546 let mut out = Vec::new();
547 if toml_section(&root_text, "package").is_some() {
548 out.push(CargoPublishFlag {
549 manifest: "Cargo.toml".to_string(),
550 package: parse_manifest("Cargo.toml", &root_text)
551 .unwrap_or_default()
552 .package,
553 policy: manifest_publish_policy(&root_text, inherited),
554 });
555 }
556 for rel in workspace_member_dirs(repo_root, fs, &root_text) {
557 let Some(text) = read_text(fs, &repo_root.join(&rel).join("Cargo.toml"), MANIFEST_LIMIT)
558 else {
559 continue;
560 };
561 let (package, _) = resolve_member_name_version(&text, ws_pkg.as_deref());
562 out.push(CargoPublishFlag {
563 manifest: format!("{rel}/Cargo.toml"),
564 package,
565 policy: manifest_publish_policy(&text, inherited),
566 });
567 }
568 out
569}
570
571fn manifest_publish_policy(text: &str, inherited: CargoPublishPolicy) -> CargoPublishPolicy {
574 let Some(block) = toml_section(text, "package") else {
575 return CargoPublishPolicy::Forbidden;
577 };
578 if inherits_workspace_publish(&block) {
579 return inherited;
580 }
581 block_publish_policy(&block)
582}
583
584fn block_publish_policy(block: &str) -> CargoPublishPolicy {
593 if let Some(registries) = toml_str_array(block, "publish") {
594 return if registries.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS) {
595 CargoPublishPolicy::Allowed
596 } else {
597 CargoPublishPolicy::Forbidden
598 };
599 }
600 match toml_bool_value(block, "publish") {
601 Some(true) => CargoPublishPolicy::Allowed,
602 Some(false) => CargoPublishPolicy::Forbidden,
603 None => {
606 if block_declares_key(block, "publish") {
607 CargoPublishPolicy::Unknown
608 } else {
609 CargoPublishPolicy::Allowed
610 }
611 }
612 }
613}
614
615fn inherits_workspace_publish(block: &str) -> bool {
618 block.lines().any(|line| {
619 let line = strip_toml_comment(line).trim();
620 line.starts_with("publish.workspace")
621 || (line.starts_with("publish")
622 && line.contains('{')
623 && line.contains("workspace")
624 && line.contains("true"))
625 })
626}
627
628fn block_declares_key(block: &str, key: &str) -> bool {
631 block.lines().any(|line| {
632 let line = strip_toml_comment(line).trim();
633 line.strip_prefix(key).is_some_and(|rest| {
634 rest.starts_with(|c: char| c.is_whitespace() || c == '=' || c == '.')
635 })
636 })
637}
638
639fn detect_manifests(repo_root: &Path, fs: &dyn Fs) -> (Vec<Ecosystem>, Vec<Package>) {
641 let mut ecosystems: Vec<Ecosystem> = Vec::new();
642 let mut packages: Vec<Package> = Vec::new();
643 for &(fname, eco) in MANIFESTS {
644 let path = repo_root.join(fname);
645 if !fs.is_file(&path) {
646 continue;
647 }
648 let text = read_text(fs, &path, MANIFEST_LIMIT);
649 let parsed = text
650 .as_deref()
651 .and_then(|t| parse_manifest(fname, t))
652 .unwrap_or_default();
653 if !ecosystems.contains(&eco) {
655 ecosystems.push(eco);
656 }
657 if fname == "Cargo.toml" && parsed.package.is_none() {
662 if let Some(text) = text.as_deref() {
663 if push_workspace_members(repo_root, fs, text, eco, &mut packages) {
664 continue;
665 }
666 }
667 }
671 if parsed.package.is_some() || fname == "Cargo.toml" || fname == "go.mod" {
674 packages.push(Package {
675 ecosystem: eco,
676 manifest: fname.to_string(),
677 package: parsed.package,
678 version: parsed.version,
679 });
680 }
681 }
682 if ecosystems.is_empty() {
684 ecosystems.push(Ecosystem::Binary);
685 }
686 (ecosystems, packages)
687}
688
689fn push_workspace_members(
706 repo_root: &Path,
707 fs: &dyn Fs,
708 root_text: &str,
709 eco: Ecosystem,
710 packages: &mut Vec<Package>,
711) -> bool {
712 let ws_pkg = toml_section(root_text, "workspace.package");
713 let dirs = workspace_member_dirs(repo_root, fs, root_text);
714 let before = packages.len();
715 for rel in &dirs {
716 let manifest_path = repo_root.join(rel).join("Cargo.toml");
717 let Some(member_text) = read_text(fs, &manifest_path, MANIFEST_LIMIT) else {
718 continue;
719 };
720 let (package, version) = resolve_member_name_version(&member_text, ws_pkg.as_deref());
721 packages.push(Package {
722 ecosystem: eco,
723 manifest: format!("{rel}/Cargo.toml"),
724 package,
725 version,
726 });
727 }
728 packages.len() > before
729}
730
731fn workspace_member_dirs(repo_root: &Path, fs: &dyn Fs, root_text: &str) -> Vec<String> {
745 let Some(ws_block) = toml_section(root_text, "workspace") else {
746 return Vec::new();
747 };
748 let members = match toml_str_array(&ws_block, "members") {
749 Some(members) if !members.is_empty() => members,
750 _ => return Vec::new(),
751 };
752 let exclude: Vec<String> = toml_str_array(&ws_block, "exclude")
753 .unwrap_or_default()
754 .iter()
755 .map(|e| e.trim_end_matches('/').to_string())
756 .collect();
757 let mut out: Vec<String> = Vec::new();
759 for member in members {
760 let member = member.trim_end_matches('/');
761 if member.is_empty() || member.starts_with('/') || member.split('/').any(|c| c == "..") {
762 continue;
763 }
764 if let Some(prefix) = glob_parent(member) {
765 let dir = if prefix.is_empty() {
767 repo_root.to_path_buf()
768 } else {
769 repo_root.join(prefix)
770 };
771 let mut names = fs.read_dir(&dir).unwrap_or_default();
772 names.sort();
773 for name in names {
774 let rel = if prefix.is_empty() {
775 name
776 } else {
777 format!("{prefix}/{name}")
778 };
779 push_member_dir(repo_root, fs, &rel, &exclude, &mut out);
780 }
781 continue;
782 }
783 if member.contains(['*', '?']) {
786 continue;
787 }
788 push_member_dir(repo_root, fs, member, &exclude, &mut out);
789 }
790 out
791}
792
793fn push_member_dir(
796 repo_root: &Path,
797 fs: &dyn Fs,
798 rel: &str,
799 exclude: &[String],
800 out: &mut Vec<String>,
801) {
802 let rel = rel.trim_end_matches('/');
803 if exclude.iter().any(|e| e == rel) || out.iter().any(|s| s == rel) {
804 return;
805 }
806 if !fs.is_file(&repo_root.join(rel).join("Cargo.toml")) {
807 return;
808 }
809 out.push(rel.to_string());
810}
811
812struct RawMember {
843 package: String,
844 version: Option<String>,
845 publishable: bool,
846 deps: Vec<(String, Option<String>)>,
848 pins: Vec<(String, Option<String>)>,
851}
852
853fn detect_rust_workspace(repo_root: &Path, fs: &dyn Fs) -> Option<RustWorkspace> {
854 let root_path = repo_root.join("Cargo.toml");
855 if !fs.is_file(&root_path) {
856 return None;
857 }
858 let root_text = read_text_full(fs, &root_path)?;
859 let dirs = workspace_member_dirs(repo_root, fs, &root_text);
860 if dirs.is_empty() {
861 return None;
862 }
863 let ws_pkg = toml_section(&root_text, "workspace.package");
864 let mut workspace_pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>> =
865 std::collections::BTreeMap::new();
866 let mut pin_parse_error = None;
867 match crate::release::bump::cargo_workspace_pin_declarations(&root_text) {
868 Ok(declarations) => {
869 for declaration in declarations {
870 workspace_pin_reqs
871 .entry(declaration.package)
872 .or_default()
873 .push(declaration.requirement);
874 }
875 }
876 Err(error) => pin_parse_error = Some(format!("Cargo.toml: {error}")),
877 }
878
879 let mut raw: Vec<RawMember> = Vec::new();
881 for rel in &dirs {
882 let Some(text) = read_text_full(fs, &repo_root.join(rel).join("Cargo.toml")) else {
883 continue;
884 };
885 let (package, version) = resolve_member_name_version(&text, ws_pkg.as_deref());
886 let Some(package) = package else { continue };
889 raw.push(RawMember {
890 package,
891 version,
892 publishable: member_publishable_to_crates_io(&text),
893 deps: member_dependency_edges(&text),
894 pins: match crate::release::bump::cargo_pin_declarations(&text) {
895 Ok(declarations) => declarations
896 .into_iter()
897 .map(|d| (d.package, d.requirement))
898 .collect(),
899 Err(error) => {
900 pin_parse_error.get_or_insert_with(|| format!("{rel}/Cargo.toml: {error}"));
901 Vec::new()
902 }
903 },
904 });
905 }
906
907 let publishable_names: std::collections::BTreeSet<&str> = raw
911 .iter()
912 .filter(|m| m.publishable)
913 .map(|m| m.package.as_str())
914 .collect();
915 let members: Vec<WorkspaceMember> = raw
916 .iter()
917 .filter(|m| m.publishable)
918 .map(|m| {
919 let mut dep_reqs: std::collections::BTreeMap<String, String> =
923 std::collections::BTreeMap::new();
924 for (name, req) in &m.deps {
925 if name.as_str() == m.package || !publishable_names.contains(name.as_str()) {
926 continue;
927 }
928 if let Some(req) = req {
929 dep_reqs.entry(name.clone()).or_insert_with(|| req.clone());
932 }
933 }
934 let mut workspace_deps: Vec<String> = m
935 .deps
936 .iter()
937 .map(|(name, _req)| name.clone())
938 .filter(|d| d.as_str() != m.package && publishable_names.contains(d.as_str()))
939 .collect();
940 workspace_deps.sort();
941 workspace_deps.dedup();
942 let mut pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>> =
943 std::collections::BTreeMap::new();
944 for (name, req) in &m.pins {
945 if name.as_str() != m.package && publishable_names.contains(name.as_str()) {
946 pin_reqs.entry(name.clone()).or_default().push(req.clone());
947 }
948 }
949 WorkspaceMember {
950 package: m.package.clone(),
951 version: m.version.clone(),
952 workspace_deps,
953 dep_reqs,
954 pin_reqs,
955 }
956 })
957 .collect();
958 if members.is_empty() {
959 return None;
960 }
961 Some(RustWorkspace {
962 members,
963 workspace_pin_reqs,
964 pin_parse_error,
965 })
966}
967
968fn member_publishable_to_crates_io(text: &str) -> bool {
976 let Some(block) = toml_section(text, "package") else {
977 return false;
982 };
983 if let Some(regs) = toml_str_array(&block, "publish") {
985 return regs.iter().any(|r| r == CRATES_IO_REGISTRY_ALIAS);
986 }
987 !matches!(toml_bool_value(&block, "publish"), Some(false))
989}
990
991const CRATES_IO_REGISTRY_ALIAS: &str = "crates-io";
995
996fn toml_bool_value(block: &str, key: &str) -> Option<bool> {
1000 for line in block.lines() {
1001 let rest = line.trim_start();
1002 let Some(rest) = rest.strip_prefix(key) else {
1003 continue;
1004 };
1005 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1007 continue;
1008 }
1009 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1010 continue;
1011 };
1012 match strip_toml_comment(rest).trim() {
1013 "true" => return Some(true),
1014 "false" => return Some(false),
1015 _ => return None,
1016 }
1017 }
1018 None
1019}
1020
1021enum DepTable {
1023 Table,
1028 Sub {
1032 key: String,
1034 package: Option<String>,
1036 local: bool,
1038 version: Option<String>,
1041 },
1042 Other,
1044}
1045
1046fn member_dependency_edges(text: &str) -> Vec<(String, Option<String>)> {
1079 let mut state = DepTable::Other;
1080 let mut out: Vec<(String, Option<String>)> = Vec::new();
1081
1082 for line in text.lines() {
1083 let t = strip_toml_comment(line).trim();
1084 if let Some(header) = t.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
1085 flush_dep_subtable(&mut state, &mut out);
1087 state = classify_dep_table(header.trim());
1088 continue;
1089 }
1090 match &mut state {
1091 DepTable::Table => {
1092 if let Some(edge) = dep_edge_from_line(t) {
1093 out.push(edge);
1094 }
1095 }
1096 DepTable::Sub {
1097 package,
1098 local,
1099 version,
1100 ..
1101 } => {
1102 if let Some((k, v)) = split_key_value(t) {
1106 match k {
1107 "package" if package.is_none() => *package = extract_quoted(v),
1108 "path" => *local = true,
1109 "workspace" if v.trim_start().starts_with("true") => *local = true,
1110 "version" if version.is_none() => *version = extract_quoted(v),
1111 _ => {}
1112 }
1113 }
1114 }
1115 DepTable::Other => {}
1116 }
1117 }
1118 flush_dep_subtable(&mut state, &mut out);
1119 out
1120}
1121
1122fn classify_dep_table(header: &str) -> DepTable {
1132 if let Some(name) = dep_subtable_name(header) {
1133 return DepTable::Sub {
1134 key: name.trim().trim_matches(['"', '\'']).to_string(),
1135 package: None,
1136 local: false,
1137 version: None,
1138 };
1139 }
1140 if !header.contains("dev-dependencies")
1142 && (header == "dependencies"
1143 || header == "build-dependencies"
1144 || header.ends_with(".dependencies")
1145 || header.ends_with(".build-dependencies"))
1146 {
1147 return DepTable::Table;
1148 }
1149 DepTable::Other
1150}
1151
1152fn dep_subtable_name(header: &str) -> Option<&str> {
1157 if header.contains("dev-dependencies") {
1158 return None;
1159 }
1160 if let Some(name) = header
1161 .strip_prefix("dependencies.")
1162 .or_else(|| header.strip_prefix("build-dependencies."))
1163 {
1164 return Some(name);
1165 }
1166 for infix in [".dependencies.", ".build-dependencies."] {
1169 if let Some(idx) = header.rfind(infix) {
1170 return Some(&header[idx + infix.len()..]);
1171 }
1172 }
1173 None
1174}
1175
1176fn dep_edge_from_line(line: &str) -> Option<(String, Option<String>)> {
1185 let (key_full, val) = split_key_value(line)?;
1186 let mut segs = key_full.split('.');
1187 let crate_key = segs.next().unwrap_or("").trim().trim_matches(['"', '\'']);
1188 if crate_key.is_empty() {
1189 return None;
1190 }
1191 match segs.next().map(str::trim) {
1192 Some("path") => Some((crate_key.to_string(), None)),
1198 Some("workspace") if val.trim_start().starts_with("true") => {
1199 Some((crate_key.to_string(), None))
1200 }
1201 Some(_) => None,
1204 None => {
1206 if !val.starts_with('{') || !inline_table_is_local(val) {
1207 return None;
1208 }
1209 let name = inline_table_package(val).unwrap_or_else(|| crate_key.to_string());
1210 Some((name, inline_table_version(val)))
1211 }
1212 }
1213}
1214
1215fn flush_dep_subtable(state: &mut DepTable, out: &mut Vec<(String, Option<String>)>) {
1219 if let DepTable::Sub {
1220 key,
1221 package,
1222 local: true,
1223 version,
1224 } = state
1225 {
1226 out.push((
1227 package.clone().unwrap_or_else(|| key.clone()),
1228 version.clone(),
1229 ));
1230 }
1231 *state = DepTable::Other;
1232}
1233
1234fn split_key_value(line: &str) -> Option<(&str, &str)> {
1238 let eq = line.find('=')?;
1239 let key = line[..eq].trim().trim_matches(['"', '\'']);
1240 if key.is_empty() {
1241 return None;
1242 }
1243 Some((key, line[eq + 1..].trim()))
1244}
1245
1246fn inline_table_is_local(inline: &str) -> bool {
1250 contains_toml_key(inline, "path") || toml_key_is_true(inline, "workspace")
1251}
1252
1253fn contains_toml_key(s: &str, key: &str) -> bool {
1257 scan_toml_key(s, key, |_after| true)
1258}
1259
1260fn toml_key_is_true(s: &str, key: &str) -> bool {
1262 scan_toml_key(s, key, |after| after.trim_start().starts_with("true"))
1263}
1264
1265fn scan_toml_key(s: &str, key: &str, accept: impl Fn(&str) -> bool) -> bool {
1270 let mut rest = s;
1271 while let Some(pos) = rest.find(key) {
1272 let prev_is_ident = rest[..pos]
1273 .chars()
1274 .next_back()
1275 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1276 let after = rest[pos + key.len()..].trim_start();
1277 if !prev_is_ident {
1278 if let Some(value) = after.strip_prefix('=') {
1279 if accept(value) {
1280 return true;
1281 }
1282 }
1283 }
1284 rest = &rest[pos + key.len()..];
1285 }
1286 false
1287}
1288
1289fn inline_table_package(inline: &str) -> Option<String> {
1297 let mut rest = inline;
1298 while let Some(pos) = rest.find("package") {
1299 let prev_is_ident = rest[..pos]
1300 .chars()
1301 .next_back()
1302 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1303 let after = rest[pos + "package".len()..].trim_start();
1304 if !prev_is_ident {
1305 if let Some(value) = after.strip_prefix('=') {
1306 return extract_quoted(value.trim_start());
1307 }
1308 }
1309 rest = &rest[pos + "package".len()..];
1312 }
1313 None
1314}
1315
1316fn inline_table_version(inline: &str) -> Option<String> {
1322 let mut rest = inline;
1323 while let Some(pos) = rest.find("version") {
1324 let prev_is_ident = rest[..pos]
1325 .chars()
1326 .next_back()
1327 .is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '-');
1328 let after = rest[pos + "version".len()..].trim_start();
1329 if !prev_is_ident {
1330 if let Some(value) = after.strip_prefix('=') {
1331 return extract_quoted(value.trim_start());
1332 }
1333 }
1334 rest = &rest[pos + "version".len()..];
1335 }
1336 None
1337}
1338
1339fn glob_parent(member: &str) -> Option<&str> {
1343 if member == "*" {
1344 return Some("");
1345 }
1346 member
1347 .strip_suffix("/*")
1348 .filter(|prefix| !prefix.contains(['*', '?']))
1349}
1350
1351fn resolve_member_name_version(
1355 member_text: &str,
1356 ws_pkg: Option<&str>,
1357) -> (Option<String>, Option<String>) {
1358 let parsed = parse_cargo(member_text).unwrap_or_default();
1359 let version = parsed.version.or_else(|| {
1360 let inherits = toml_section(member_text, "package")
1364 .is_some_and(|block| field_inherits_workspace(&block, "version"));
1365 if inherits {
1366 ws_pkg.and_then(|block| toml_str_value(block, "version", false))
1367 } else {
1368 None
1369 }
1370 });
1371 (parsed.package, version)
1372}
1373
1374fn detect_description(
1377 repo_root: &Path,
1378 fs: &dyn Fs,
1379 packages: &[Package],
1380 readme_text: Option<&str>,
1381) -> Option<String> {
1382 let manifest_desc = packages.iter().find_map(|p| {
1383 let text = read_text(fs, &repo_root.join(&p.manifest), MANIFEST_LIMIT)?;
1384 let desc = parse_manifest(&p.manifest, &text)?.description?;
1385 (!desc.is_empty()).then_some(desc)
1386 });
1387 if let Some(desc) = manifest_desc {
1388 return Some(truncate_chars(desc.trim(), DESCRIPTION_CHARS));
1389 }
1390 readme_text?.lines().find_map(|line| {
1391 let s = line.trim();
1392 let is_prose =
1393 !s.is_empty() && !s.starts_with('#') && !s.starts_with('!') && !s.starts_with('>');
1394 is_prose.then(|| truncate_chars(s, DESCRIPTION_CHARS))
1395 })
1396}
1397
1398#[derive(Debug, Default)]
1402struct ParsedManifest {
1403 package: Option<String>,
1404 version: Option<String>,
1405 description: Option<String>,
1406}
1407
1408fn parse_manifest(fname: &str, text: &str) -> Option<ParsedManifest> {
1415 let base = Path::new(fname)
1416 .file_name()
1417 .and_then(|n| n.to_str())
1418 .unwrap_or(fname);
1419 match base {
1420 "Cargo.toml" => parse_cargo(text),
1421 "package.json" => parse_package_json(text),
1422 "pyproject.toml" => Some(parse_pyproject(text)),
1423 "go.mod" => Some(parse_gomod(text)),
1424 _ => None, }
1426}
1427
1428fn parse_cargo(text: &str) -> Option<ParsedManifest> {
1431 let block = toml_section(text, "package")?;
1432 Some(ParsedManifest {
1433 package: toml_str_value(&block, "name", false),
1434 version: toml_str_value(&block, "version", false),
1435 description: toml_str_value(&block, "description", true),
1436 })
1437}
1438
1439fn parse_package_json(text: &str) -> Option<ParsedManifest> {
1440 let value: serde_json::Value = serde_json::from_str(text).ok()?;
1441 let field = |key: &str| {
1442 value
1443 .get(key)
1444 .and_then(serde_json::Value::as_str)
1445 .map(str::to_string)
1446 };
1447 Some(ParsedManifest {
1448 package: field("name"),
1449 version: field("version"),
1450 description: field("description"),
1451 })
1452}
1453
1454fn parse_pyproject(text: &str) -> ParsedManifest {
1457 let mut parsed = ParsedManifest::default();
1458 if let Some(block) = toml_section(text, "project") {
1459 parsed.package = toml_str_value(&block, "name", false);
1460 parsed.version = toml_str_value(&block, "version", false);
1461 parsed.description = toml_str_value(&block, "description", true);
1462 }
1463 if parsed.package.is_none() {
1464 if let Some(block) = toml_section(text, "tool.poetry") {
1465 parsed.package = toml_str_value(&block, "name", false);
1466 parsed.version = toml_str_value(&block, "version", false);
1467 parsed.description = toml_str_value(&block, "description", true);
1468 }
1469 }
1470 parsed
1471}
1472
1473fn parse_gomod(text: &str) -> ParsedManifest {
1476 let module = text.lines().find_map(|line| {
1477 line.strip_prefix("module")
1478 .filter(|rest| rest.starts_with(char::is_whitespace))
1479 .and_then(|rest| rest.split_whitespace().next())
1480 .map(str::to_string)
1481 });
1482 ParsedManifest {
1483 package: module,
1484 version: None,
1485 description: None,
1486 }
1487}
1488
1489fn toml_section(text: &str, header: &str) -> Option<String> {
1493 let needle = format!("[{header}]");
1494 let mut in_section = false;
1495 let mut out = String::new();
1496 for line in text.lines() {
1497 if in_section {
1498 if line.starts_with('[') {
1499 break;
1500 }
1501 out.push_str(line);
1502 out.push('\n');
1503 } else if line.starts_with(&needle) {
1504 in_section = true;
1505 }
1506 }
1507 in_section.then_some(out)
1508}
1509
1510fn toml_str_value(block: &str, key: &str, allow_empty: bool) -> Option<String> {
1514 for line in block.lines() {
1515 let rest = line.trim_start();
1516 let Some(rest) = rest.strip_prefix(key) else {
1517 continue;
1518 };
1519 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1523 continue;
1524 }
1525 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1526 continue;
1527 };
1528 let Some(value) = extract_quoted(rest.trim_start()) else {
1529 continue;
1530 };
1531 if value.is_empty() && !allow_empty {
1532 return None;
1533 }
1534 return Some(value);
1535 }
1536 None
1537}
1538
1539fn toml_str_array(block: &str, key: &str) -> Option<Vec<String>> {
1546 let mut acc = String::new();
1548 let mut collecting = false;
1549 for line in block.lines() {
1550 let line = strip_toml_comment(line);
1553 if collecting {
1554 acc.push_str(line);
1555 acc.push('\n');
1556 if line.contains(']') {
1557 break;
1558 }
1559 continue;
1560 }
1561 let rest = line.trim_start();
1562 let Some(rest) = rest.strip_prefix(key) else {
1563 continue;
1564 };
1565 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1567 continue;
1568 }
1569 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1570 continue;
1571 };
1572 acc.push_str(rest);
1573 acc.push('\n');
1574 collecting = true;
1575 if rest.contains(']') {
1576 break;
1577 }
1578 }
1579 if !collecting {
1580 return None;
1581 }
1582 let start = acc.find('[')?;
1584 let end = acc[start..].find(']')? + start;
1585 let mut inner = &acc[start + 1..end];
1586 let mut out = Vec::new();
1587 while let Some(pos) = inner.find(['"', '\'']) {
1588 let quote = inner.as_bytes()[pos] as char;
1589 let after = &inner[pos + 1..];
1590 let Some(close) = after.find(quote) else {
1591 break;
1592 };
1593 out.push(after[..close].to_string());
1594 inner = &after[close + 1..];
1595 }
1596 Some(out)
1597}
1598
1599fn field_inherits_workspace(block: &str, key: &str) -> bool {
1605 let dotted = format!("{key}.workspace");
1606 for line in block.lines() {
1607 let t = strip_toml_comment(line).trim_start();
1608 if let Some(rest) = t.strip_prefix(&dotted) {
1610 let rest = rest.trim_start();
1611 if let Some(rest) = rest.strip_prefix('=') {
1612 if is_true_literal(rest.trim_start()) {
1613 return true;
1614 }
1615 }
1616 continue;
1617 }
1618 if let Some(rest) = t.strip_prefix(key) {
1620 if !rest.starts_with(|c: char| c.is_whitespace() || c == '=') {
1621 continue;
1622 }
1623 let Some(rest) = rest.trim_start().strip_prefix('=') else {
1624 continue;
1625 };
1626 let v = rest.trim_start();
1627 if v.starts_with('{') && inline_table_has_workspace_true(v) {
1630 return true;
1631 }
1632 }
1633 }
1634 false
1635}
1636
1637fn is_true_literal(s: &str) -> bool {
1640 match s.strip_prefix("true") {
1641 Some(rest) => {
1642 let rest = rest.trim_start();
1643 rest.is_empty() || rest.starts_with(['#', '}', ','])
1644 }
1645 None => false,
1646 }
1647}
1648
1649fn inline_table_has_workspace_true(inline: &str) -> bool {
1653 let compact: String = inline.chars().filter(|c| !c.is_whitespace()).collect();
1654 compact.trim_start_matches('{').split(',').any(|entry| {
1655 entry
1656 .strip_prefix("workspace=true")
1657 .is_some_and(|r| r.is_empty() || r == "}")
1658 })
1659}
1660
1661fn strip_toml_comment(line: &str) -> &str {
1664 let mut quote: Option<u8> = None;
1665 for (i, &b) in line.as_bytes().iter().enumerate() {
1666 match quote {
1667 Some(q) => {
1668 if b == q {
1669 quote = None;
1670 }
1671 }
1672 None => match b {
1673 b'"' | b'\'' => quote = Some(b),
1674 b'#' => return &line[..i],
1675 _ => {}
1676 },
1677 }
1678 }
1679 line
1680}
1681
1682fn extract_quoted(s: &str) -> Option<String> {
1688 let quote = s.chars().next().filter(|&c| c == '"' || c == '\'')?;
1689 let s = &s[1..];
1690 let end = s.find(quote)?;
1691 Some(s[..end].to_string())
1692}
1693
1694fn semver_parse(tag: &str) -> Option<(u64, u64, u64, bool)> {
1702 parse_semver_core(strip_pkg_prefix(tag))
1703}
1704
1705fn strip_pkg_prefix(tag: &str) -> &str {
1708 let bytes = tag.as_bytes();
1709 for i in (0..bytes.len()).rev() {
1710 if matches!(bytes[i], b'@' | b'/' | b'-') {
1711 let rest = &tag[i + 1..];
1712 if starts_with_version(rest) {
1713 return rest;
1714 }
1715 }
1716 }
1717 tag
1718}
1719
1720fn starts_with_version(s: &str) -> bool {
1722 let mut rest = s.strip_prefix('v').unwrap_or(s);
1723 for i in 0..3 {
1724 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1725 if digits == 0 {
1726 return false;
1727 }
1728 rest = &rest[digits..];
1729 if i < 2 {
1730 match rest.strip_prefix('.') {
1731 Some(r) => rest = r,
1732 None => return false,
1733 }
1734 }
1735 }
1736 true
1737}
1738
1739fn parse_semver_core(core: &str) -> Option<(u64, u64, u64, bool)> {
1742 let mut rest = core.strip_prefix('v').unwrap_or(core);
1743 let mut nums = [0u64; 3];
1744 for (i, slot) in nums.iter_mut().enumerate() {
1745 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
1746 if digits == 0 {
1747 return None;
1748 }
1749 *slot = rest[..digits].parse().ok()?;
1750 rest = &rest[digits..];
1751 if i < 2 {
1752 rest = rest.strip_prefix('.')?;
1753 }
1754 }
1755 let pre = match rest.chars().next() {
1756 None | Some('+') => false,
1757 Some('-') => true,
1758 Some(_) => return None, };
1760 Some((nums[0], nums[1], nums[2], pre))
1761}
1762
1763fn version_ge_1_0(version: Option<&str>) -> bool {
1765 let Some(v) = version else {
1766 return false;
1767 };
1768 let s = v.strip_prefix('v').unwrap_or(v);
1769 let digits = s.bytes().take_while(u8::is_ascii_digit).count();
1770 if digits == 0 || !s[digits..].starts_with('.') {
1772 return false;
1773 }
1774 s[..digits].parse::<u64>().is_ok_and(|n| n >= 1)
1775}
1776
1777fn read_text(fs: &dyn Fs, path: &Path, limit: usize) -> Option<String> {
1785 let bytes = fs.read(path).ok()?;
1786 Some(
1787 String::from_utf8_lossy(&bytes)
1788 .chars()
1789 .take(limit)
1790 .collect(),
1791 )
1792}
1793
1794fn read_text_full(fs: &dyn Fs, path: &Path) -> Option<String> {
1798 Some(String::from_utf8_lossy(&fs.read(path).ok()?).into_owned())
1799}
1800
1801fn count_lines(text: String) -> usize {
1803 text.lines().filter(|l| !l.trim().is_empty()).count()
1804}
1805
1806fn truncate_chars(s: &str, n: usize) -> String {
1808 s.chars().take(n).collect()
1809}
1810
1811#[cfg(test)]
1812mod tests {
1813 use super::*;
1814 use std::collections::{HashMap, HashSet};
1815 use std::path::PathBuf;
1816
1817 #[derive(Default)]
1820 struct FakeFs {
1821 files: HashMap<PathBuf, Vec<u8>>,
1822 dirs: HashSet<PathBuf>,
1823 }
1824
1825 impl FakeFs {
1826 fn file(mut self, path: &str, contents: &str) -> Self {
1827 let p = PathBuf::from(path);
1828 let mut cur = p.parent();
1830 while let Some(dir) = cur {
1831 if dir.as_os_str().is_empty() {
1832 break;
1833 }
1834 self.dirs.insert(dir.to_path_buf());
1835 cur = dir.parent();
1836 }
1837 self.files.insert(p, contents.as_bytes().to_vec());
1838 self
1839 }
1840
1841 fn dir(mut self, path: &str) -> Self {
1842 self.dirs.insert(PathBuf::from(path));
1843 self
1844 }
1845 }
1846
1847 impl Fs for FakeFs {
1848 fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
1849 self.files
1850 .get(path)
1851 .cloned()
1852 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1853 }
1854 fn exists(&self, path: &Path) -> bool {
1855 self.files.contains_key(path) || self.dirs.contains(path)
1856 }
1857 fn is_dir(&self, path: &Path) -> bool {
1858 self.dirs.contains(path)
1859 }
1860 fn is_file(&self, path: &Path) -> bool {
1861 self.files.contains_key(path)
1862 }
1863 fn read_dir(&self, dir: &Path) -> std::io::Result<Vec<String>> {
1864 if !self.dirs.contains(dir) {
1865 return Err(std::io::Error::from(std::io::ErrorKind::NotFound));
1866 }
1867 let mut names: Vec<String> = self
1868 .files
1869 .keys()
1870 .chain(self.dirs.iter())
1871 .filter(|p| p.parent() == Some(dir))
1872 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1873 .collect();
1874 names.sort();
1875 Ok(names)
1876 }
1877 }
1878
1879 #[derive(Default)]
1880 struct FakeGit {
1881 work_tree: bool,
1882 head: Option<String>,
1883 shortlog_all: String,
1884 shortlog_recent: String,
1885 tags: Vec<String>,
1886 }
1887
1888 impl GitRepo for FakeGit {
1889 fn head_commit(&self) -> std::io::Result<String> {
1890 self.head
1891 .clone()
1892 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))
1893 }
1894 fn is_work_tree(&self) -> bool {
1895 self.work_tree
1896 }
1897 fn shortlog(&self, since: Option<&str>) -> std::io::Result<String> {
1898 if !self.work_tree {
1899 return Err(std::io::Error::from(std::io::ErrorKind::Other));
1900 }
1901 Ok(if since.is_some() {
1902 self.shortlog_recent.clone()
1903 } else {
1904 self.shortlog_all.clone()
1905 })
1906 }
1907 fn tags(&self) -> std::io::Result<Vec<String>> {
1908 if self.work_tree {
1909 Ok(self.tags.clone())
1910 } else {
1911 Err(std::io::Error::from(std::io::ErrorKind::Other))
1912 }
1913 }
1914 fn git_common_dir(&self) -> std::io::Result<PathBuf> {
1915 Ok(PathBuf::from("/repo/.git"))
1916 }
1917 }
1918
1919 fn git_with(total: usize, recent: usize, tags: &[&str]) -> FakeGit {
1921 let lines = |n: usize| {
1922 (0..n)
1923 .map(|i| format!(" 3\tDev {i} <dev{i}@example.com>"))
1924 .collect::<Vec<_>>()
1925 .join("\n")
1926 };
1927 FakeGit {
1928 work_tree: true,
1929 head: Some("deadbeef".to_string()),
1930 shortlog_all: lines(total),
1931 shortlog_recent: lines(recent),
1932 tags: tags.iter().map(|t| (*t).to_string()).collect(),
1933 }
1934 }
1935
1936 fn repo() -> &'static Path {
1937 Path::new("/repo")
1938 }
1939
1940 #[test]
1943 fn empty_repo_is_spike_binary() {
1944 let facts = gather(repo(), &FakeFs::default(), &FakeGit::default());
1945 assert!(!facts.is_git);
1946 assert!(!facts.has_commits);
1947 assert_eq!(facts.ecosystems, vec![Ecosystem::Binary]);
1948 assert!(facts.packages.is_empty());
1949 assert_eq!(facts.committers_total, 0);
1950 assert_eq!(facts.committers_recent_year, 0);
1951 assert!(facts.tags.is_empty());
1952 assert!(!facts.has_ci);
1953 assert_eq!(facts.dependency_bot, None);
1954 assert_eq!(facts.description, None);
1955 assert!(facts.maturity_signals.spike);
1957 assert_eq!(facts.inferred_maturity, Maturity::Spike);
1958 }
1959
1960 #[test]
1961 fn unborn_repo_has_no_commits() {
1962 let git = FakeGit {
1965 work_tree: true,
1966 head: None,
1967 ..FakeGit::default()
1968 };
1969 let facts = gather(repo(), &FakeFs::default(), &git);
1970 assert!(facts.is_git);
1971 assert!(!facts.has_commits);
1972 assert_eq!(facts.committers_total, 0);
1973 assert!(facts.tags.is_empty());
1974 }
1975
1976 #[test]
1979 fn cargo_package_name_version_description() {
1980 let cargo = "[package]\nname = \"rg\"\nversion = \"0.3.0\"\n\
1981 description = \"a fast grep\"\n\n[dependencies]\nserde = \"1\"\n";
1982 let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
1983 let facts = gather(repo(), &fs, &FakeGit::default());
1984 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
1985 assert_eq!(facts.packages.len(), 1);
1986 let p = &facts.packages[0];
1987 assert_eq!(p.ecosystem, Ecosystem::Rust);
1988 assert_eq!(p.manifest, "Cargo.toml");
1989 assert_eq!(p.package.as_deref(), Some("rg"));
1990 assert_eq!(p.version.as_deref(), Some("0.3.0"));
1991 assert_eq!(facts.description.as_deref(), Some("a fast grep"));
1992 }
1993
1994 #[test]
1995 fn cargo_virtual_workspace_with_no_resolvable_member_keeps_null_entry() {
1996 let fs = FakeFs::default().file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n");
2000 let facts = gather(repo(), &fs, &FakeGit::default());
2001 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
2002 assert_eq!(facts.packages.len(), 1);
2003 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2004 assert_eq!(facts.packages[0].package, None);
2005 assert_eq!(facts.packages[0].version, None);
2006 }
2007
2008 #[test]
2009 fn cargo_virtual_workspace_enumerates_members() {
2010 let root = "[workspace]\nresolver = \"2\"\n\
2013 members = [\"crates/core\", \"crates/cli\"]\n\n\
2014 [workspace.package]\nversion = \"0.1.0\"\nedition = \"2021\"\n";
2015 let core = "[package]\nname = \"acme-core\"\nversion.workspace = true\n\
2016 edition.workspace = true\ndescription = \"the core lib\"\n";
2017 let cli = "[package]\nname = \"acme-cli\"\nversion = \"2.3.4\"\n\
2018 description = \"the cli\"\n";
2019 let fs = FakeFs::default()
2020 .file("/repo/Cargo.toml", root)
2021 .file("/repo/crates/core/Cargo.toml", core)
2022 .file("/repo/crates/cli/Cargo.toml", cli);
2023 let facts = gather(repo(), &fs, &FakeGit::default());
2024 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust]);
2025 assert_eq!(facts.packages.len(), 2);
2026 let core_pkg = &facts.packages[0];
2028 assert_eq!(core_pkg.ecosystem, Ecosystem::Rust);
2029 assert_eq!(core_pkg.manifest, "crates/core/Cargo.toml");
2030 assert_eq!(core_pkg.package.as_deref(), Some("acme-core"));
2031 assert_eq!(core_pkg.version.as_deref(), Some("0.1.0"));
2033 let cli_pkg = &facts.packages[1];
2034 assert_eq!(cli_pkg.manifest, "crates/cli/Cargo.toml");
2035 assert_eq!(cli_pkg.package.as_deref(), Some("acme-cli"));
2036 assert_eq!(cli_pkg.version.as_deref(), Some("2.3.4"));
2037 assert_eq!(facts.description.as_deref(), Some("the core lib"));
2039 }
2040
2041 #[test]
2044 fn rust_workspace_graph_captures_lib_bin_edge() {
2045 let root = "[workspace]\nmembers = [\"crates/core\", \"crates/cli\"]\n\n\
2048 [workspace.package]\nversion = \"0.1.6\"\n";
2049 let core = "[package]\nname = \"octl-core\"\nversion.workspace = true\n\
2050 publish = true\n";
2051 let cli = "[package]\nname = \"orchestratectl\"\nversion.workspace = true\n\
2052 publish = true\n\n[dependencies]\n\
2053 octl-core = { path = \"../core\", version = \"=0.1.6\" }\n\
2054 serde = \"1\"\n";
2055 let fs = FakeFs::default()
2056 .file("/repo/Cargo.toml", root)
2057 .file("/repo/crates/core/Cargo.toml", core)
2058 .file("/repo/crates/cli/Cargo.toml", cli);
2059 let ws = detect_rust_workspace(repo(), &fs).expect("a members-bearing workspace");
2060 let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
2062 assert_eq!(names, vec!["octl-core", "orchestratectl"]);
2063 assert!(ws.members[0].workspace_deps.is_empty());
2066 assert_eq!(ws.members[1].workspace_deps, vec!["octl-core".to_string()]);
2067 assert_eq!(ws.members[0].version.as_deref(), Some("0.1.6"));
2068 }
2069
2070 #[test]
2071 fn rust_workspace_graph_drops_unpublishable_members() {
2072 let root = "[workspace]\nmembers = [\"a\", \"b\", \"c\"]\n";
2076 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n"; let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n";
2078 let c = "[package]\nname = \"c\"\nversion = \"1.0.0\"\n\
2079 publish = [\"my-registry\"]\n\n[dependencies]\n\
2080 b = { path = \"../b\" }\n";
2081 let fs = FakeFs::default()
2082 .file("/repo/Cargo.toml", root)
2083 .file("/repo/a/Cargo.toml", a)
2084 .file("/repo/b/Cargo.toml", b)
2085 .file("/repo/c/Cargo.toml", c);
2086 let ws = detect_rust_workspace(repo(), &fs).expect("at least `a` is publishable");
2087 let names: Vec<_> = ws.members.iter().map(|m| m.package.as_str()).collect();
2088 assert_eq!(names, vec!["a"], "only the publishable member survives");
2089 }
2090
2091 #[test]
2092 fn rust_workspace_graph_honors_crates_io_allow_list() {
2093 let root = "[workspace]\nmembers = [\"a\"]\n";
2095 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\
2096 publish = [\"crates-io\"]\n";
2097 let fs = FakeFs::default()
2098 .file("/repo/Cargo.toml", root)
2099 .file("/repo/a/Cargo.toml", a);
2100 let ws = detect_rust_workspace(repo(), &fs).expect("crates-io allow-listed");
2101 assert_eq!(ws.members.len(), 1);
2102 }
2103
2104 #[test]
2105 fn cargo_publish_evidence_reads_the_root_package() {
2106 let fs = FakeFs::default().file(
2107 "/repo/Cargo.toml",
2108 "[package]\nname = \"solo\"\nversion = \"0.1.0\"\npublish = false\n",
2109 );
2110 assert_eq!(
2111 cargo_publish_evidence(repo(), &fs),
2112 vec![CargoPublishFlag {
2113 manifest: "Cargo.toml".to_string(),
2114 package: Some("solo".to_string()),
2115 policy: CargoPublishPolicy::Forbidden,
2116 }]
2117 );
2118 }
2119
2120 #[test]
2121 fn cargo_publish_evidence_reads_every_workspace_member() {
2122 let fs = FakeFs::default()
2123 .file(
2124 "/repo/Cargo.toml",
2125 "[workspace]\nmembers = [\"a\", \"b\"]\n",
2126 )
2127 .file(
2128 "/repo/a/Cargo.toml",
2129 "[package]\nname = \"a\"\nversion = \"1.0.0\"\n",
2130 )
2131 .file(
2132 "/repo/b/Cargo.toml",
2133 "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n",
2134 );
2135 assert_eq!(
2136 policies(&fs),
2137 vec![
2138 ("a/Cargo.toml".to_string(), CargoPublishPolicy::Allowed),
2139 ("b/Cargo.toml".to_string(), CargoPublishPolicy::Forbidden),
2140 ]
2141 );
2142 }
2143
2144 #[test]
2148 fn cargo_publish_evidence_reads_a_hybrid_root_and_its_members() {
2149 let fs = FakeFs::default()
2150 .file(
2151 "/repo/Cargo.toml",
2152 "[package]\nname = \"root\"\nversion = \"1.0.0\"\n\n\
2153 [workspace]\nmembers = [\"crates/private\"]\n",
2154 )
2155 .file(
2156 "/repo/crates/private/Cargo.toml",
2157 "[package]\nname = \"private\"\nversion = \"1.0.0\"\npublish = false\n",
2158 );
2159 assert_eq!(
2160 policies(&fs),
2161 vec![
2162 ("Cargo.toml".to_string(), CargoPublishPolicy::Allowed),
2163 (
2164 "crates/private/Cargo.toml".to_string(),
2165 CargoPublishPolicy::Forbidden
2166 ),
2167 ]
2168 );
2169 }
2170
2171 #[test]
2173 fn cargo_publish_evidence_covers_every_publish_value_shape() {
2174 let cases = [
2175 ("", CargoPublishPolicy::Allowed), ("publish = true\n", CargoPublishPolicy::Allowed),
2177 ("publish = false\n", CargoPublishPolicy::Forbidden),
2178 ("publish=false\n", CargoPublishPolicy::Forbidden), ("publish = false # private\n", CargoPublishPolicy::Forbidden), ("publish = []\n", CargoPublishPolicy::Forbidden), ("publish = [\"crates-io\"]\n", CargoPublishPolicy::Allowed),
2182 (
2183 "publish = [\"my-registry\"]\n",
2184 CargoPublishPolicy::Forbidden,
2185 ),
2186 (
2188 "publish = { workspace = false }\n",
2189 CargoPublishPolicy::Unknown,
2190 ),
2191 ];
2192 for (line, expected) in cases {
2193 let fs = FakeFs::default().file(
2194 "/repo/Cargo.toml",
2195 &format!("[package]\nname = \"solo\"\nversion = \"0.1.0\"\n{line}"),
2196 );
2197 assert_eq!(
2198 cargo_publish_evidence(repo(), &fs)[0].policy,
2199 expected,
2200 "publish line {line:?}"
2201 );
2202 }
2203 }
2204
2205 #[test]
2209 fn cargo_publish_evidence_ignores_a_publish_key_outside_the_package_table() {
2210 let fs = FakeFs::default().file(
2211 "/repo/Cargo.toml",
2212 "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n\n\
2213 [package.metadata.release]\npublish = false\n\n\
2214 [dependencies]\npublish = \"1.0\"\n",
2215 );
2216 assert_eq!(
2217 cargo_publish_evidence(repo(), &fs)[0].policy,
2218 CargoPublishPolicy::Allowed
2219 );
2220 }
2221
2222 #[test]
2225 fn cargo_publish_evidence_resolves_workspace_inheritance() {
2226 let member = "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n";
2227 for (ws_publish, expected) in [
2228 ("publish = false\n", CargoPublishPolicy::Forbidden),
2229 ("publish = true\n", CargoPublishPolicy::Allowed),
2230 ("", CargoPublishPolicy::Allowed), ] {
2232 let fs = FakeFs::default()
2233 .file(
2234 "/repo/Cargo.toml",
2235 &format!(
2236 "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"1.0.0\"\n{ws_publish}"
2237 ),
2238 )
2239 .file("/repo/a/Cargo.toml", member);
2240 assert_eq!(
2241 cargo_publish_evidence(repo(), &fs)[0].policy,
2242 expected,
2243 "[workspace.package] {ws_publish:?}"
2244 );
2245 }
2246
2247 let fs = FakeFs::default()
2250 .file("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n")
2251 .file("/repo/a/Cargo.toml", member);
2252 assert_eq!(
2253 cargo_publish_evidence(repo(), &fs)[0].policy,
2254 CargoPublishPolicy::Unknown
2255 );
2256 }
2257
2258 #[test]
2259 fn facts_report_exposes_the_normalizers_exact_tri_state_evidence() {
2260 let fs = FakeFs::default()
2261 .file(
2262 "/repo/Cargo.toml",
2263 "[workspace]\nmembers = [\"allowed\", \"inherited\", \"unknown\"]\n\
2264 \n[workspace.package]\npublish = false\n",
2265 )
2266 .file(
2267 "/repo/allowed/Cargo.toml",
2268 "[package]\nname = \"allowed\"\npublish = true\n",
2269 )
2270 .file(
2271 "/repo/inherited/Cargo.toml",
2272 "[package]\nname = \"inherited\"\npublish.workspace = true\n",
2273 )
2274 .file(
2275 "/repo/unknown/Cargo.toml",
2276 "[package]\nname = \"unknown\"\npublish = { workspace = false }\n",
2277 );
2278
2279 let report = gather_report(repo(), &fs, &FakeGit::default());
2280 assert_eq!(report.cargo_publish, cargo_publish_evidence(repo(), &fs));
2281 assert_eq!(
2282 report
2283 .cargo_publish
2284 .iter()
2285 .map(|e| e.policy)
2286 .collect::<Vec<_>>(),
2287 vec![
2288 CargoPublishPolicy::Allowed,
2289 CargoPublishPolicy::Forbidden,
2290 CargoPublishPolicy::Unknown,
2291 ]
2292 );
2293
2294 let json = serde_json::to_value(&report).expect("facts report serializes");
2295 assert_eq!(json["cargo_publish"][0]["manifest"], "allowed/Cargo.toml");
2296 assert_eq!(json["cargo_publish"][0]["policy"], "allowed");
2297 assert_eq!(json["cargo_publish"][1]["policy"], "forbidden");
2298 assert_eq!(json["cargo_publish"][2]["policy"], "unknown");
2299 }
2300
2301 #[test]
2302 fn cargo_publish_evidence_is_empty_without_a_manifest() {
2303 assert!(cargo_publish_evidence(repo(), &FakeFs::default()).is_empty());
2305 }
2306
2307 fn policies(fs: &FakeFs) -> Vec<(String, CargoPublishPolicy)> {
2309 cargo_publish_evidence(repo(), fs)
2310 .into_iter()
2311 .map(|m| (m.manifest, m.policy))
2312 .collect()
2313 }
2314
2315 #[test]
2316 fn rust_workspace_graph_none_for_single_crate_repo() {
2317 let cargo = "[package]\nname = \"solo\"\nversion = \"0.1.0\"\n";
2320 let fs = FakeFs::default().file("/repo/Cargo.toml", cargo);
2321 assert!(detect_rust_workspace(repo(), &fs).is_none());
2322 }
2323
2324 #[test]
2325 fn rust_workspace_graph_dep_rename_via_inline_package_key() {
2326 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2329 let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
2330 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2331 alias = { package = \"real-core\", path = \"../core\" }\n";
2332 let fs = FakeFs::default()
2333 .file("/repo/Cargo.toml", root)
2334 .file("/repo/core/Cargo.toml", core)
2335 .file("/repo/cli/Cargo.toml", cli);
2336 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2337 let cli = ws
2338 .members
2339 .iter()
2340 .find(|m| m.package == "cli")
2341 .expect("cli member");
2342 assert_eq!(cli.workspace_deps, vec!["real-core".to_string()]);
2343 }
2344
2345 #[test]
2346 fn rust_workspace_graph_ignores_registry_dep_sharing_a_member_name() {
2347 let root = "[workspace]\nmembers = [\"shared\", \"cli\"]\n";
2352 let shared = "[package]\nname = \"shared\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2353 cli = { path = \"../cli\" }\n"; let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2355 shared = \"1\"\n"; let fs = FakeFs::default()
2357 .file("/repo/Cargo.toml", root)
2358 .file("/repo/shared/Cargo.toml", shared)
2359 .file("/repo/cli/Cargo.toml", cli);
2360 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2361 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2362 let shared = ws.members.iter().find(|m| m.package == "shared").unwrap();
2363 assert!(
2364 cli.workspace_deps.is_empty(),
2365 "a registry dep sharing a member name is not an edge"
2366 );
2367 assert_eq!(
2368 shared.workspace_deps,
2369 vec!["cli".to_string()],
2370 "the real path edge is still detected"
2371 );
2372 }
2373
2374 #[test]
2375 fn rust_workspace_graph_ignores_registry_inline_table_without_path() {
2376 let root = "[workspace]\nmembers = [\"a\", \"b\"]\n";
2378 let a = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n";
2379 let b = "[package]\nname = \"b\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2380 a = { version = \"1\", features = [\"x\"] }\n";
2381 let fs = FakeFs::default()
2382 .file("/repo/Cargo.toml", root)
2383 .file("/repo/a/Cargo.toml", a)
2384 .file("/repo/b/Cargo.toml", b);
2385 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2386 let b = ws.members.iter().find(|m| m.package == "b").unwrap();
2387 assert!(
2388 b.workspace_deps.is_empty(),
2389 "an inline registry dep (no path/workspace) is not an edge"
2390 );
2391 }
2392
2393 #[test]
2394 fn rust_workspace_graph_records_the_lockstep_pin_requirement() {
2395 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2398 let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
2399 let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
2400 octl-core = { path = \"../core\", version = \"=0.4.0\" }\n";
2401 let fs = FakeFs::default()
2402 .file("/repo/Cargo.toml", root)
2403 .file("/repo/core/Cargo.toml", core)
2404 .file("/repo/cli/Cargo.toml", cli);
2405 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2406 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2407 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2408 assert_eq!(
2409 cli.dep_reqs.get("octl-core").map(String::as_str),
2410 Some("=0.4.0")
2411 );
2412 assert_eq!(
2413 cli.pin_reqs.get("octl-core"),
2414 Some(&vec![Some("=0.4.0".to_string())])
2415 );
2416 }
2417
2418 #[test]
2419 fn rust_workspace_records_inherited_root_exact_pin() {
2420 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n\
2421 [workspace.package]\nversion = \"0.4.0\"\n\
2422 [workspace.dependencies]\ncore = {\npath = \"core\",\nversion = \"=0.4.0\"\n}\n";
2423 let core = "[package]\nname = \"core\"\nversion.workspace = true\n";
2424 let cli = "[package]\nname = \"cli\"\nversion.workspace = true\n\n[dependencies]\ncore.workspace = true\n";
2425 let fs = FakeFs::default()
2426 .file("/repo/Cargo.toml", root)
2427 .file("/repo/core/Cargo.toml", core)
2428 .file("/repo/cli/Cargo.toml", cli);
2429 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2430
2431 assert_eq!(
2432 ws.workspace_pin_reqs.get("core"),
2433 Some(&vec![Some("=0.4.0".to_string())])
2434 );
2435 let cli = ws
2436 .members
2437 .iter()
2438 .find(|member| member.package == "cli")
2439 .unwrap();
2440 assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2441 }
2442
2443 #[test]
2444 fn rust_workspace_pin_requirements_preserve_all_dependency_tables() {
2445 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2446 let core = "[package]\nname = \"core\"\nversion = \"0.4.0\"\n";
2447 let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[dev-dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[build-dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n[target.'cfg(unix)'.dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n";
2448 let fs = FakeFs::default()
2449 .file("/repo/Cargo.toml", root)
2450 .file("/repo/core/Cargo.toml", core)
2451 .file("/repo/cli/Cargo.toml", cli);
2452 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2453 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2454
2455 assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2456 assert_eq!(
2457 cli.pin_reqs.get("core"),
2458 Some(&vec![Some("=0.4.0".to_string()); 4])
2459 );
2460 }
2461
2462 #[test]
2463 fn rust_workspace_pin_discovery_reads_the_complete_manifest() {
2464 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2465 let core = "[package]\nname = \"core\"\nversion = \"0.4.0\"\n";
2466 let mut cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\ncore = { path = \"../core\", version = \"=0.4.0\" }\n".to_string();
2467 cli.push('#');
2468 cli.push_str(&"x".repeat(MANIFEST_LIMIT));
2469 cli.push_str("\n[dev-dependencies]\ncore = { path = \"../core\", version = \"^0.4\" }\n");
2470 let fs = FakeFs::default()
2471 .file("/repo/Cargo.toml", root)
2472 .file("/repo/core/Cargo.toml", core)
2473 .file("/repo/cli/Cargo.toml", &cli);
2474 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2475 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2476
2477 assert_eq!(
2478 cli.pin_reqs.get("core"),
2479 Some(&vec![Some("=0.4.0".to_string()), Some("^0.4".to_string())])
2480 );
2481 }
2482
2483 #[test]
2484 fn rust_workspace_graph_omits_req_for_a_pathonly_edge() {
2485 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2489 let core = "[package]\nname = \"octl-core\"\nversion = \"0.4.0\"\n";
2490 let cli = "[package]\nname = \"cli\"\nversion = \"0.4.0\"\n\n[dependencies]\n\
2491 octl-core = { path = \"../core\" }\n";
2492 let fs = FakeFs::default()
2493 .file("/repo/Cargo.toml", root)
2494 .file("/repo/core/Cargo.toml", core)
2495 .file("/repo/cli/Cargo.toml", cli);
2496 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2497 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2498 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2499 assert!(!cli.dep_reqs.contains_key("octl-core"));
2500 }
2501
2502 #[test]
2503 fn rust_workspace_graph_reads_target_specific_dependency_edges() {
2504 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2507 let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
2508 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
2509 [target.'cfg(unix)'.dependencies]\n\
2510 octl-core = { path = \"../core\", version = \"=1.0.0\" }\n";
2511 let fs = FakeFs::default()
2512 .file("/repo/Cargo.toml", root)
2513 .file("/repo/core/Cargo.toml", core)
2514 .file("/repo/cli/Cargo.toml", cli);
2515 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2516 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2517 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2518 }
2519
2520 #[test]
2521 fn rust_workspace_graph_reads_subtable_path_edge_and_rename() {
2522 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2525 let core = "[package]\nname = \"real-core\"\nversion = \"1.0.0\"\n";
2526 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n\
2527 [dependencies.alias]\npackage = \"real-core\"\npath = \"../core\"\n\
2528 version = \"=1.0.0\"\n\n\
2529 [dependencies.serde]\nversion = \"1\"\n"; let fs = FakeFs::default()
2531 .file("/repo/Cargo.toml", root)
2532 .file("/repo/core/Cargo.toml", core)
2533 .file("/repo/cli/Cargo.toml", cli);
2534 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2535 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2536 assert_eq!(
2537 cli.workspace_deps,
2538 vec!["real-core".to_string()],
2539 "the renamed path sub-table is the only edge; the version-only one is not"
2540 );
2541 }
2542
2543 #[test]
2544 fn rust_workspace_graph_reads_dotted_key_path_edge() {
2545 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2548 let core = "[package]\nname = \"core\"\nversion = \"1.0.0\"\n";
2549 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2550 core.path = \"../core\"\ncore.version = \"=1.0.0\"\n";
2551 let fs = FakeFs::default()
2552 .file("/repo/Cargo.toml", root)
2553 .file("/repo/core/Cargo.toml", core)
2554 .file("/repo/cli/Cargo.toml", cli);
2555 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2556 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2557 assert_eq!(cli.workspace_deps, vec!["core".to_string()]);
2558 }
2559
2560 #[test]
2561 fn rust_workspace_graph_dep_rename_ignores_package_substring_in_a_path() {
2562 let root = "[workspace]\nmembers = [\"core\", \"cli\"]\n";
2565 let core = "[package]\nname = \"octl-core\"\nversion = \"1.0.0\"\n";
2566 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2567 octl-core = { path = \"../my-package-core\", version = \"1\" }\n";
2568 let fs = FakeFs::default()
2569 .file("/repo/Cargo.toml", root)
2570 .file("/repo/core/Cargo.toml", core)
2571 .file("/repo/cli/Cargo.toml", cli);
2572 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2575 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2576 assert_eq!(cli.workspace_deps, vec!["octl-core".to_string()]);
2577 }
2578
2579 #[test]
2580 fn rust_workspace_graph_reads_build_dependency_edges() {
2581 let root = "[workspace]\nmembers = [\"gen\", \"app\"]\n";
2583 let gen = "[package]\nname = \"gen\"\nversion = \"1.0.0\"\n";
2584 let app = "[package]\nname = \"app\"\nversion = \"1.0.0\"\n\n\
2585 [build-dependencies]\ngen = { path = \"../gen\" }\n";
2586 let fs = FakeFs::default()
2587 .file("/repo/Cargo.toml", root)
2588 .file("/repo/gen/Cargo.toml", gen)
2589 .file("/repo/app/Cargo.toml", app);
2590 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2591 let app = ws.members.iter().find(|m| m.package == "app").unwrap();
2592 assert_eq!(app.workspace_deps, vec!["gen".to_string()]);
2593 }
2594
2595 #[test]
2596 fn rust_workspace_graph_excludes_dev_dependency_edges() {
2597 let root = "[workspace]\nmembers = [\"lib\", \"cli\"]\n";
2600 let lib = "[package]\nname = \"lib\"\nversion = \"1.0.0\"\n\n\
2601 [dev-dependencies]\ncli = { path = \"../cli\" }\n";
2602 let cli = "[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\n\
2603 lib = { path = \"../lib\" }\n";
2604 let fs = FakeFs::default()
2605 .file("/repo/Cargo.toml", root)
2606 .file("/repo/lib/Cargo.toml", lib)
2607 .file("/repo/cli/Cargo.toml", cli);
2608 let ws = detect_rust_workspace(repo(), &fs).expect("workspace");
2609 let lib = ws.members.iter().find(|m| m.package == "lib").unwrap();
2610 let cli = ws.members.iter().find(|m| m.package == "cli").unwrap();
2611 assert!(
2612 lib.workspace_deps.is_empty(),
2613 "dev-dependency edge is not an ordering edge"
2614 );
2615 assert_eq!(cli.workspace_deps, vec!["lib".to_string()]);
2616 }
2617
2618 #[test]
2619 fn cargo_workspace_inline_version_inheritance() {
2620 let root = "[workspace]\nmembers = [\"m\"]\n\n\
2622 [workspace.package]\nversion = \"1.5.0\"\n";
2623 let member = "[package]\nname = \"m\"\nversion = { workspace = true }\n";
2624 let fs = FakeFs::default()
2625 .file("/repo/Cargo.toml", root)
2626 .file("/repo/m/Cargo.toml", member);
2627 let facts = gather(repo(), &fs, &FakeGit::default());
2628 assert_eq!(facts.packages.len(), 1);
2629 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2630 assert_eq!(facts.packages[0].version.as_deref(), Some("1.5.0"));
2631 assert!(facts.has_ge_1_0_release);
2633 }
2634
2635 #[test]
2636 fn cargo_workspace_multiline_members_array() {
2637 let root = "[workspace]\nmembers = [\n \"a\",\n \"b\",\n]\n\n\
2639 [workspace.package]\nversion = \"0.2.0\"\n";
2640 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2641 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2642 let fs = FakeFs::default()
2643 .file("/repo/Cargo.toml", root)
2644 .file("/repo/a/Cargo.toml", a)
2645 .file("/repo/b/Cargo.toml", b);
2646 let facts = gather(repo(), &fs, &FakeGit::default());
2647 let names: Vec<_> = facts
2648 .packages
2649 .iter()
2650 .map(|p| p.package.as_deref())
2651 .collect();
2652 assert_eq!(names, vec![Some("a"), Some("b")]);
2653 assert!(facts
2654 .packages
2655 .iter()
2656 .all(|p| p.version.as_deref() == Some("0.2.0")));
2657 }
2658
2659 #[test]
2660 fn cargo_workspace_member_without_workspace_package_table() {
2661 let root = "[workspace]\nmembers = [\"m\"]\n";
2664 let member = "[package]\nname = \"m\"\nversion.workspace = true\n";
2665 let fs = FakeFs::default()
2666 .file("/repo/Cargo.toml", root)
2667 .file("/repo/m/Cargo.toml", member);
2668 let facts = gather(repo(), &fs, &FakeGit::default());
2669 assert_eq!(facts.packages.len(), 1);
2670 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2671 assert_eq!(facts.packages[0].version, None);
2672 }
2673
2674 #[test]
2675 fn cargo_workspace_glob_members_are_expanded() {
2676 let root = "[workspace]\nmembers = [\"crates/*\"]\n\n\
2679 [workspace.package]\nversion = \"0.4.0\"\n";
2680 let a = "[package]\nname = \"za\"\nversion.workspace = true\n";
2681 let b = "[package]\nname = \"mb\"\nversion.workspace = true\n";
2682 let fs = FakeFs::default()
2683 .file("/repo/Cargo.toml", root)
2684 .file("/repo/crates/za/Cargo.toml", a)
2685 .file("/repo/crates/mb/Cargo.toml", b)
2686 .file("/repo/crates/README.md", "not a crate\n");
2687 let facts = gather(repo(), &fs, &FakeGit::default());
2688 let names: Vec<_> = facts
2690 .packages
2691 .iter()
2692 .map(|p| p.package.as_deref())
2693 .collect();
2694 assert_eq!(names, vec![Some("mb"), Some("za")]);
2695 assert_eq!(facts.packages[0].manifest, "crates/mb/Cargo.toml");
2696 assert!(facts
2697 .packages
2698 .iter()
2699 .all(|p| p.version.as_deref() == Some("0.4.0")));
2700 }
2701
2702 #[test]
2703 fn cargo_workspace_exclude_and_dedup() {
2704 let root = "[workspace]\nmembers = [\"crates/*\", \"crates/a\"]\n\
2707 exclude = [\"crates/b\"]\n\n\
2708 [workspace.package]\nversion = \"0.1.0\"\n";
2709 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2710 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2711 let fs = FakeFs::default()
2712 .file("/repo/Cargo.toml", root)
2713 .file("/repo/crates/a/Cargo.toml", a)
2714 .file("/repo/crates/b/Cargo.toml", b);
2715 let facts = gather(repo(), &fs, &FakeGit::default());
2716 let names: Vec<_> = facts
2718 .packages
2719 .iter()
2720 .map(|p| p.package.as_deref())
2721 .collect();
2722 assert_eq!(names, vec![Some("a")]);
2723 }
2724
2725 #[test]
2726 fn cargo_workspace_commented_out_member_is_ignored() {
2727 let root = "[workspace]\nmembers = [\n \"a\",\n # \"b\",\n]\n\n\
2729 [workspace.package]\nversion = \"0.1.0\"\n";
2730 let a = "[package]\nname = \"a\"\nversion.workspace = true\n";
2731 let b = "[package]\nname = \"b\"\nversion.workspace = true\n";
2732 let fs = FakeFs::default()
2733 .file("/repo/Cargo.toml", root)
2734 .file("/repo/a/Cargo.toml", a)
2735 .file("/repo/b/Cargo.toml", b);
2736 let facts = gather(repo(), &fs, &FakeGit::default());
2737 let names: Vec<_> = facts
2738 .packages
2739 .iter()
2740 .map(|p| p.package.as_deref())
2741 .collect();
2742 assert_eq!(names, vec![Some("a")]);
2743 }
2744
2745 #[test]
2746 fn cargo_workspace_rejects_escaping_member_paths() {
2747 let root = "[workspace]\nmembers = [\"../outside\", \"/abs\"]\n";
2750 let outside = "[package]\nname = \"outside\"\nversion = \"9.9.9\"\n";
2751 let fs = FakeFs::default()
2752 .file("/repo/Cargo.toml", root)
2753 .file("/outside/Cargo.toml", outside)
2754 .file("/abs/Cargo.toml", outside);
2755 let facts = gather(repo(), &fs, &FakeGit::default());
2756 assert_eq!(facts.packages.len(), 1);
2757 assert_eq!(facts.packages[0].manifest, "Cargo.toml");
2758 assert_eq!(facts.packages[0].package, None);
2759 }
2760
2761 #[test]
2762 fn cargo_workspace_inheritance_scoped_and_boolean_strict() {
2763 let root = "[workspace]\nmembers = [\"m\", \"n\"]\n\n\
2766 [workspace.package]\nversion = \"7.7.7\"\n";
2767 let m = "[package]\nname = \"m\"\n\n\
2768 [package.metadata.tool]\nversion.workspace = true\n";
2769 let n = "[package]\nname = \"n\"\nversion.workspace = trueish\n";
2770 let fs = FakeFs::default()
2771 .file("/repo/Cargo.toml", root)
2772 .file("/repo/m/Cargo.toml", m)
2773 .file("/repo/n/Cargo.toml", n);
2774 let facts = gather(repo(), &fs, &FakeGit::default());
2775 assert_eq!(facts.packages[0].package.as_deref(), Some("m"));
2777 assert_eq!(facts.packages[0].version, None);
2778 assert_eq!(facts.packages[1].package.as_deref(), Some("n"));
2779 assert_eq!(facts.packages[1].version, None);
2780 }
2781
2782 #[test]
2783 fn cargo_workspace_inline_inheritance_rejects_false_positive() {
2784 let root = "[workspace]\nmembers = [\"yes\", \"no\"]\n\n\
2787 [workspace.package]\nversion = \"3.0.0\"\n";
2788 let yes = "[package]\nname = \"yes\"\nversion = { workspace = true }\n";
2789 let no = "[package]\nname = \"no\"\nversion = { workspace = false, path = \"x\" }\n";
2790 let fs = FakeFs::default()
2791 .file("/repo/Cargo.toml", root)
2792 .file("/repo/yes/Cargo.toml", yes)
2793 .file("/repo/no/Cargo.toml", no);
2794 let facts = gather(repo(), &fs, &FakeGit::default());
2795 assert_eq!(facts.packages[0].version.as_deref(), Some("3.0.0"));
2796 assert_eq!(facts.packages[1].version, None);
2797 }
2798
2799 #[test]
2800 fn package_json_parsed_and_go_mod_module() {
2801 let pkg = r#"{"name": "@acme/cli", "version": "2.0.0", "description": "cli"}"#;
2802 let fs = FakeFs::default()
2803 .file("/repo/package.json", pkg)
2804 .file("/repo/go.mod", "module github.com/acme/tool\n\ngo 1.22\n");
2805 let facts = gather(repo(), &fs, &FakeGit::default());
2806 assert_eq!(facts.ecosystems, vec![Ecosystem::Node, Ecosystem::Go]);
2807 let node = facts
2808 .packages
2809 .iter()
2810 .find(|p| p.ecosystem == Ecosystem::Node)
2811 .unwrap();
2812 assert_eq!(node.package.as_deref(), Some("@acme/cli"));
2813 assert_eq!(node.version.as_deref(), Some("2.0.0"));
2814 let go = facts
2815 .packages
2816 .iter()
2817 .find(|p| p.ecosystem == Ecosystem::Go)
2818 .unwrap();
2819 assert_eq!(go.package.as_deref(), Some("github.com/acme/tool"));
2820 assert_eq!(facts.description.as_deref(), Some("cli"));
2822 }
2823
2824 #[test]
2825 fn pyproject_project_then_poetry_fallback() {
2826 let project = "[project]\nname = \"widget\"\nversion = \"1.4.0\"\n\
2827 description = \"a widget\"\n";
2828 let facts = gather(
2829 repo(),
2830 &FakeFs::default().file("/repo/pyproject.toml", project),
2831 &FakeGit::default(),
2832 );
2833 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2834 assert_eq!(facts.packages[0].version.as_deref(), Some("1.4.0"));
2835
2836 let poetry = "[tool.poetry]\nname = \"legacy\"\nversion = \"0.1.0\"\n";
2837 let facts = gather(
2838 repo(),
2839 &FakeFs::default().file("/repo/pyproject.toml", poetry),
2840 &FakeGit::default(),
2841 );
2842 assert_eq!(facts.packages[0].package.as_deref(), Some("legacy"));
2843 }
2844
2845 #[test]
2846 fn pyproject_single_quoted_strings_parse() {
2847 let project = "[project]\nname = 'widget'\nversion = '1.2.0'\n\
2850 description = 'a widget'\n";
2851 let facts = gather(
2852 repo(),
2853 &FakeFs::default().file("/repo/pyproject.toml", project),
2854 &FakeGit::default(),
2855 );
2856 assert_eq!(facts.packages[0].package.as_deref(), Some("widget"));
2857 assert_eq!(facts.packages[0].version.as_deref(), Some("1.2.0"));
2858 assert!(facts.has_ge_1_0_release);
2859 assert_eq!(facts.description.as_deref(), Some("a widget"));
2860 }
2861
2862 #[test]
2863 fn toml_key_matches_whole_token_not_prefix() {
2864 let cargo = "[package]\nnamespace = \"nope\"\nversion-code = \"9\"\n\
2867 name = \"real\"\nversion = \"0.2.0\"\n";
2868 let facts = gather(
2869 repo(),
2870 &FakeFs::default().file("/repo/Cargo.toml", cargo),
2871 &FakeGit::default(),
2872 );
2873 assert_eq!(facts.packages[0].package.as_deref(), Some("real"));
2874 assert_eq!(facts.packages[0].version.as_deref(), Some("0.2.0"));
2875 }
2876
2877 #[test]
2878 fn setup_py_marks_python_but_adds_no_package() {
2879 let fs = FakeFs::default().file("/repo/setup.py", "from setuptools import setup\n");
2880 let facts = gather(repo(), &fs, &FakeGit::default());
2881 assert_eq!(facts.ecosystems, vec![Ecosystem::Python]);
2882 assert!(facts.packages.is_empty());
2883 }
2884
2885 #[test]
2886 fn ecosystems_emit_in_canonical_order() {
2887 let fs = FakeFs::default().file("/repo/go.mod", "module x\n").file(
2889 "/repo/Cargo.toml",
2890 "[package]\nname = \"a\"\nversion = \"0.1.0\"\n",
2891 );
2892 let facts = gather(repo(), &fs, &FakeGit::default());
2893 assert_eq!(facts.ecosystems, vec![Ecosystem::Rust, Ecosystem::Go]);
2894 }
2895
2896 #[test]
2899 fn workflows_dir_counts_only_when_non_empty() {
2900 let empty = FakeFs::default().dir("/repo/.github/workflows").file(
2902 "/repo/Cargo.toml",
2903 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2904 );
2905 assert!(!gather(repo(), &empty, &FakeGit::default()).has_ci);
2906
2907 let with_wf = FakeFs::default()
2909 .file("/repo/.github/workflows/ci.yml", "on: push\n")
2910 .file(
2911 "/repo/Cargo.toml",
2912 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
2913 );
2914 assert!(gather(repo(), &with_wf, &FakeGit::default()).has_ci);
2915 }
2916
2917 #[test]
2918 fn single_file_ci_configs_count_on_existence() {
2919 for name in [
2920 ".gitlab-ci.yml",
2921 "azure-pipelines.yml",
2922 ".drone.yml",
2923 "Jenkinsfile",
2924 ] {
2925 let fs = FakeFs::default().file(&format!("/repo/{name}"), "ci\n");
2926 assert!(
2927 gather(repo(), &fs, &FakeGit::default()).has_ci,
2928 "{name} should count as CI"
2929 );
2930 }
2931 }
2932
2933 #[test]
2934 fn distribution_surface_detects_cargo_dist_and_only_tag_push_workflows() {
2935 let fs = FakeFs::default()
2936 .file("/repo/dist-workspace.toml", "[dist]\nci = \"github\"\n")
2937 .file("/repo/Cargo.toml", "[workspace.metadata.dist]\ndist = true\n")
2938 .file(
2939 "/repo/.github/workflows/release.yml",
2940 "on:\n push:\n tags:\n - 'v*'\n",
2941 )
2942 .file(
2943 "/repo/.github/workflows/ci.yml",
2944 "on:\n push:\n branches: [main]\n pull_request:\n tags: [never-a-trigger]\n",
2945 );
2946 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2947 assert!(surface.has_cargo_dist);
2948 assert_eq!(
2949 surface.cargo_dist_evidence,
2950 vec![
2951 "dist-workspace.toml".to_string(),
2952 "Cargo.toml ([workspace.metadata.dist])".to_string()
2953 ]
2954 );
2955 assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
2956 assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2957 }
2958
2959 #[test]
2960 fn cargo_publish_workflow_detection_is_empty_when_workflow_is_missing() {
2961 let fs = FakeFs::default().file(
2962 "/repo/Cargo.toml",
2963 "[package]\nname = \"missing-workflow\"\n",
2964 );
2965 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2966 assert!(surface.tag_triggered_workflows.is_empty());
2967 assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2968 }
2969
2970 #[test]
2971 fn dispatch_only_cargo_publish_workflow_is_not_tag_triggered_evidence() {
2972 let fs = FakeFs::default().file(
2973 "/repo/.github/workflows/publish.yml",
2974 "on:\n workflow_dispatch:\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: cargo publish\n",
2975 );
2976 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2977 assert!(surface.tag_triggered_workflows.is_empty());
2978 assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
2979 }
2980
2981 #[test]
2982 fn direct_tag_triggered_cargo_publish_workflow_is_detected() {
2983 let fs = FakeFs::default().file(
2984 "/repo/.github/workflows/publish.yaml",
2985 "on:\n push:\n tags:\n - 'v*'\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: cargo publish --locked\n",
2986 );
2987 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
2988 assert_eq!(surface.tag_triggered_workflows, vec!["publish.yaml"]);
2989 assert_eq!(
2990 surface.tag_triggered_cargo_publish_workflows,
2991 vec!["publish.yaml"]
2992 );
2993 }
2994
2995 #[test]
2996 fn cargo_publish_command_requires_a_real_crates_io_publish() {
2997 for command in [
2998 "cargo publish --dry-run",
2999 "cargo publish --help",
3000 "cargo publish --registry internal",
3001 "cargo publish --index https://example.invalid",
3002 "echo cargo publish",
3003 "# cargo publish",
3004 ] {
3005 assert!(
3006 !command_runs_cargo_publish(command),
3007 "{command:?} is not credible crates.io publish evidence"
3008 );
3009 }
3010 for command in [
3011 "cargo publish --locked",
3012 "cargo +stable publish --locked",
3013 "cd crates/demo && cargo publish --registry crates-io",
3014 "CARGO_TERM_COLOR=always cargo publish; echo done",
3015 ] {
3016 assert!(
3017 command_runs_cargo_publish(command),
3018 "{command:?} should be credible crates.io publish evidence"
3019 );
3020 }
3021 }
3022
3023 #[test]
3024 fn tag_triggered_local_reusable_cargo_publish_workflow_is_detected() {
3025 let fs = FakeFs::default()
3026 .file(
3027 "/repo/.github/workflows/release.yml",
3028 "on:\n push:\n tags:\n - 'v*'\njobs:\n crates:\n uses: ./.github/workflows/publish.yml\n",
3029 )
3030 .file(
3031 "/repo/.github/workflows/publish.yml",
3032 "on:\n workflow_call:\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: cargo publish --locked\n",
3033 );
3034 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3035 assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
3036 assert_eq!(
3037 surface.tag_triggered_cargo_publish_workflows,
3038 vec!["release.yml"]
3039 );
3040 }
3041
3042 #[test]
3043 fn local_workflow_without_workflow_call_is_not_credible_publish_evidence() {
3044 let fs = FakeFs::default()
3045 .file(
3046 "/repo/.github/workflows/release.yml",
3047 "on:\n push:\n tags:\n - 'v*'\njobs:\n crates:\n uses: ./.github/workflows/publish.yml\n",
3048 )
3049 .file(
3050 "/repo/.github/workflows/publish.yml",
3051 "on:\n workflow_dispatch:\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - run: cargo publish --locked\n",
3052 );
3053 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3054 assert_eq!(surface.tag_triggered_workflows, vec!["release.yml"]);
3055 assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
3056 }
3057
3058 #[test]
3059 fn distribution_surface_is_empty_without_dist_or_tag_push() {
3060 let fs = FakeFs::default()
3061 .file("/repo/Cargo.toml", "[package]\nname = \"plain\"\n")
3062 .file(
3063 "/repo/.github/workflows/ci.yaml",
3064 "on:\n push:\n branches: [main]\n",
3065 );
3066 let surface = gather(repo(), &fs, &FakeGit::default()).distribution_surface;
3067 assert!(!surface.has_cargo_dist);
3068 assert!(surface.cargo_dist_evidence.is_empty());
3069 assert!(surface.tag_triggered_workflows.is_empty());
3070 assert!(surface.tag_triggered_cargo_publish_workflows.is_empty());
3071 }
3072
3073 #[test]
3074 fn dependency_bot_and_issues_dir() {
3075 let dependabot = FakeFs::default().file("/repo/.github/dependabot.yml", "version: 2\n");
3076 assert_eq!(
3077 gather(repo(), &dependabot, &FakeGit::default()).dependency_bot,
3078 Some("dependabot".to_string())
3079 );
3080 let renovate = FakeFs::default().file("/repo/renovate.json", "{}\n");
3081 assert_eq!(
3082 gather(repo(), &renovate, &FakeGit::default()).dependency_bot,
3083 Some("renovate".to_string())
3084 );
3085 let both = FakeFs::default()
3087 .file("/repo/.github/dependabot.yml", "version: 2\n")
3088 .file("/repo/renovate.json", "{}\n");
3089 assert_eq!(
3090 gather(repo(), &both, &FakeGit::default()).dependency_bot,
3091 Some("dependabot".to_string())
3092 );
3093 let issues = FakeFs::default().dir("/repo/issues");
3094 assert!(gather(repo(), &issues, &FakeGit::default()).has_issues_dir);
3095 }
3096
3097 #[test]
3100 fn readme_self_label_and_prose_description() {
3101 let readme = "# My Tool\n\n> a quote\n\nStatus: private, early. Not much yet.\n";
3102 let fs = FakeFs::default().file("/repo/README.md", readme);
3103 let facts = gather(repo(), &fs, &FakeGit::default());
3104 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
3105 assert_eq!(
3107 facts.description.as_deref(),
3108 Some("Status: private, early. Not much yet.")
3109 );
3110 }
3111
3112 #[test]
3113 fn description_truncates_to_120_chars() {
3114 let long = "x".repeat(200);
3115 let fs = FakeFs::default().file("/repo/README.md", &format!("intro\n{long}\n"));
3116 let facts = gather(repo(), &fs, &FakeGit::default());
3117 let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{long}\"\n");
3120 let fs2 = FakeFs::default().file("/repo/Cargo.toml", &cargo);
3121 let facts2 = gather(repo(), &fs2, &FakeGit::default());
3122 assert_eq!(facts.description.as_deref(), Some("intro"));
3123 assert_eq!(
3125 facts2.description.as_deref().map(|d| d.chars().count()),
3126 Some(120)
3127 );
3128 }
3129
3130 #[test]
3131 fn read_limit_counts_chars_not_bytes() {
3132 let desc = "く".repeat(60); let cargo = format!("[package]\nname=\"a\"\nversion=\"0.1.0\"\ndescription=\"{desc}\"\n");
3136 let fs = FakeFs::default().file("/repo/Cargo.toml", &cargo);
3137 let facts = gather(repo(), &fs, &FakeGit::default());
3138 assert_eq!(facts.description.as_deref(), Some(desc.as_str()));
3139 assert!(!facts.description.as_deref().unwrap().contains('\u{FFFD}'));
3141 }
3142
3143 #[test]
3146 fn semver_parse_plain_prefixed_and_prerelease() {
3147 assert_eq!(semver_parse("v1.2.3"), Some((1, 2, 3, false)));
3148 assert_eq!(semver_parse("1.2.3"), Some((1, 2, 3, false)));
3149 assert_eq!(semver_parse("core-v1.2.3"), Some((1, 2, 3, false)));
3150 assert_eq!(semver_parse("@acme/cli@2.0.0"), Some((2, 0, 0, false)));
3151 assert_eq!(semver_parse("1.2.3-rc1"), Some((1, 2, 3, true)));
3152 assert_eq!(semver_parse("1.2.3+build"), Some((1, 2, 3, false)));
3153 assert_eq!(semver_parse("nightly"), None);
3154 assert_eq!(semver_parse("1.2"), None);
3155 assert_eq!(semver_parse("1.2.3.4"), None);
3156 }
3157
3158 #[test]
3159 fn ge_1_0_release_from_tag_but_not_from_prerelease() {
3160 let fs = FakeFs::default().file(
3161 "/repo/Cargo.toml",
3162 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
3163 );
3164 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.9.0", "v1.0.0"]));
3166 assert!(facts.has_semver_tag);
3167 assert!(facts.has_ge_1_0_release);
3168 let fs2 = FakeFs::default().file(
3170 "/repo/Cargo.toml",
3171 "[package]\nname=\"a\"\nversion=\"0.9.0\"\n",
3172 );
3173 let facts2 = gather(repo(), &fs2, &git_with(1, 1, &["v1.0.0-rc1"]));
3174 assert!(facts2.has_semver_tag);
3175 assert!(!facts2.has_ge_1_0_release);
3176 }
3177
3178 #[test]
3179 fn ge_1_0_release_from_manifest_version() {
3180 let fs = FakeFs::default().file(
3181 "/repo/Cargo.toml",
3182 "[package]\nname=\"a\"\nversion=\"1.4.0\"\n",
3183 );
3184 let facts = gather(repo(), &fs, &FakeGit::default());
3185 assert!(facts.has_ge_1_0_release);
3186 }
3187
3188 #[test]
3189 fn version_ge_1_0_requires_dot_after_major() {
3190 assert!(version_ge_1_0(Some("1.0.0")));
3191 assert!(version_ge_1_0(Some("v2.3")));
3192 assert!(version_ge_1_0(Some("2024.1")));
3193 assert!(!version_ge_1_0(Some("0.9.9")));
3194 assert!(!version_ge_1_0(Some("1"))); assert!(!version_ge_1_0(None));
3196 }
3197
3198 #[test]
3201 fn production_needs_two_recent_committers_ge_1_0_and_ci() {
3202 let fs = FakeFs::default()
3203 .file(
3204 "/repo/Cargo.toml",
3205 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
3206 )
3207 .file("/repo/.github/workflows/ci.yml", "on: push\n");
3208 let facts = gather(repo(), &fs, &git_with(4, 3, &["v1.2.0"]));
3209 assert!(facts.has_ci);
3210 assert!(facts.has_ge_1_0_release);
3211 assert!(facts.maturity_signals.production);
3212 assert_eq!(facts.inferred_maturity, Maturity::Production);
3213 }
3214
3215 #[test]
3216 fn mvp_when_ci_present_but_not_production_grade() {
3217 let fs = FakeFs::default()
3219 .file(
3220 "/repo/Cargo.toml",
3221 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
3222 )
3223 .file("/repo/.github/workflows/ci.yml", "on: push\n");
3224 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.3.0"]));
3225 assert!(!facts.maturity_signals.production);
3226 assert!(!facts.maturity_signals.spike);
3227 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3228 }
3229
3230 fn zerover_fs(bot: &str) -> FakeFs {
3233 FakeFs::default()
3234 .file(
3235 "/repo/Cargo.toml",
3236 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3237 )
3238 .file("/repo/.github/workflows/ci.yml", "on: push\n")
3239 .file(&format!("/repo/{bot}"), "version: 2\n")
3240 }
3241
3242 #[test]
3243 fn pre_1_0_with_full_release_infra_is_production() {
3244 let fs = zerover_fs(".github/dependabot.yml");
3250 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
3251 assert!(facts.has_ci);
3252 assert!(!facts.has_ge_1_0_release);
3253 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
3254 assert!(facts.maturity_signals.production);
3255 assert_eq!(facts.inferred_maturity, Maturity::Production);
3256 }
3257
3258 #[test]
3259 fn renovate_unlocks_the_zerover_release_path() {
3260 let fs = zerover_fs("renovate.json");
3263 let facts = gather(repo(), &fs, &git_with(2, 2, &["v0.1.0", "v0.2.0"]));
3264 assert_eq!(facts.dependency_bot.as_deref(), Some("renovate"));
3265 assert!(facts.maturity_signals.production);
3266 assert_eq!(facts.inferred_maturity, Maturity::Production);
3267 }
3268
3269 #[test]
3270 fn bare_0x_with_only_a_tag_is_not_production() {
3271 let fs = FakeFs::default().file(
3276 "/repo/Cargo.toml",
3277 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3278 );
3279 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.6.0"]));
3280 assert!(!facts.has_ci);
3281 assert!(!facts.has_ge_1_0_release);
3282 assert_eq!(facts.dependency_bot, None);
3283 assert!(!facts.maturity_signals.production);
3284 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3286 }
3287
3288 #[test]
3289 fn zerover_v0_0_x_tag_is_not_a_shipped_release() {
3290 let fs = zerover_fs(".github/dependabot.yml");
3295 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.0.1", "v0.0.2"]));
3296 assert!(facts.has_ci);
3297 assert_eq!(facts.dependency_bot.as_deref(), Some("dependabot"));
3298 assert!(!facts.maturity_signals.production);
3299 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3300 }
3301
3302 #[test]
3303 fn pre_1_0_release_infra_requires_release_cadence() {
3304 let fs = zerover_fs(".github/dependabot.yml");
3308 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.1.0"]));
3309 assert!(!facts.maturity_signals.production);
3310 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3311 }
3312
3313 #[test]
3314 fn pre_1_0_release_infra_requires_dependency_bot() {
3315 let fs = FakeFs::default()
3319 .file(
3320 "/repo/Cargo.toml",
3321 "[package]\nname=\"a\"\nversion=\"0.6.0\"\n",
3322 )
3323 .file("/repo/.github/workflows/ci.yml", "on: push\n");
3324 let facts = gather(repo(), &fs, &git_with(3, 3, &["v0.5.0", "v0.6.0"]));
3325 assert_eq!(facts.dependency_bot, None);
3326 assert!(!facts.maturity_signals.production);
3327 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3328 }
3329
3330 #[test]
3331 fn pre_1_0_release_infra_ignores_prerelease_tags_for_cadence() {
3332 let fs = zerover_fs(".github/dependabot.yml");
3337 let facts = gather(
3338 repo(),
3339 &fs,
3340 &git_with(3, 3, &["v0.5.0", "v0.6.0-rc1", "v0.7.0-rc1"]),
3341 );
3342 assert!(facts.has_semver_tag);
3343 assert!(!facts.maturity_signals.production);
3344 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3345 }
3346
3347 #[test]
3348 fn pre_1_0_release_infra_requires_two_recent_committers() {
3349 let fs = zerover_fs(".github/dependabot.yml");
3352 let facts = gather(repo(), &fs, &git_with(1, 1, &["v0.5.0", "v0.6.0"]));
3353 assert!(!facts.maturity_signals.production);
3354 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3355 }
3356
3357 #[test]
3358 fn ge_1_0_release_reaches_production_without_a_dependency_bot() {
3359 let fs = FakeFs::default()
3363 .file(
3364 "/repo/Cargo.toml",
3365 "[package]\nname=\"a\"\nversion=\"1.2.0\"\n",
3366 )
3367 .file("/repo/.github/workflows/ci.yml", "on: push\n");
3368 let facts = gather(repo(), &fs, &git_with(2, 2, &["v1.2.0"]));
3369 assert!(facts.has_ge_1_0_release);
3370 assert_eq!(facts.dependency_bot, None);
3371 assert!(facts.maturity_signals.production);
3372 assert_eq!(facts.inferred_maturity, Maturity::Production);
3373 }
3374
3375 #[test]
3376 fn spike_forced_by_readme_label_even_with_multiple_committers() {
3377 let fs = FakeFs::default()
3379 .file(
3380 "/repo/Cargo.toml",
3381 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
3382 )
3383 .file(
3384 "/repo/README.md",
3385 "# X\n\nThis is an experimental prototype.\n",
3386 );
3387 let facts = gather(repo(), &fs, &git_with(3, 3, &[]));
3388 assert_eq!(facts.readme_self_label.as_deref(), Some("spike"));
3389 assert!(facts.maturity_signals.spike);
3390 assert_eq!(facts.inferred_maturity, Maturity::Spike);
3391 }
3392
3393 #[test]
3394 fn multi_committer_no_ci_no_label_is_mvp_not_spike() {
3395 let fs = FakeFs::default().file(
3398 "/repo/Cargo.toml",
3399 "[package]\nname=\"a\"\nversion=\"0.1.0\"\n",
3400 );
3401 let facts = gather(repo(), &fs, &git_with(3, 2, &[]));
3402 assert!(!facts.maturity_signals.spike);
3403 assert_eq!(facts.inferred_maturity, Maturity::Mvp);
3404 }
3405
3406 #[test]
3409 fn same_repo_same_facts() {
3410 let build = || {
3411 FakeFs::default()
3412 .file(
3413 "/repo/Cargo.toml",
3414 "[package]\nname=\"a\"\nversion=\"0.3.0\"\n",
3415 )
3416 .file("/repo/.github/workflows/ci.yml", "on: push\n")
3417 };
3418 let a = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
3419 let b = gather(repo(), &build(), &git_with(2, 2, &["v0.3.0"]));
3420 assert_eq!(
3421 serde_json::to_string(&a).unwrap(),
3422 serde_json::to_string(&b).unwrap()
3423 );
3424 }
3425}