1use std::process::Command;
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use super::{Channel, canonical_dir};
15use crate::error::RkError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum TagStyle {
21 Prefixed,
23 Bare,
25 Unknown,
27}
28
29#[derive(Debug, Clone, Serialize)]
31pub struct ChannelEvidence {
32 pub channel: Channel,
34 pub evidence: Vec<String>,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct Manifest {
41 pub name: Option<String>,
43 pub version: Option<String>,
45 pub bins: Vec<String>,
47 pub repository: Option<String>,
49 pub binstall: Option<Option<String>>,
52}
53
54#[derive(Debug, Clone)]
56pub struct Source {
57 pub path: Utf8PathBuf,
59 pub tech: Option<&'static str>,
61 pub name: Option<String>,
63 pub version: Option<String>,
65 pub bins: Vec<String>,
67 pub owner_repo: Option<String>,
69 pub host: Option<String>,
71 pub flake_package: bool,
73 pub dist_github: bool,
76 pub binstall_github: bool,
79 pub tag_style: TagStyle,
81 pub channels: Vec<ChannelEvidence>,
83}
84
85impl Source {
86 #[must_use]
89 pub fn bin(&self) -> Option<&str> {
90 self.bins
91 .first()
92 .map(String::as_str)
93 .or(self.name.as_deref())
94 }
95
96 #[must_use]
98 pub fn has(&self, channel: Channel) -> bool {
99 self.channels.iter().any(|c| c.channel == channel)
100 }
101}
102
103pub fn observe(path: &Utf8Path) -> Result<Source, RkError> {
110 let path = canonical_dir(path, "source")?;
111 let cargo = read_optional(&path.join("Cargo.toml"))?.map(|t| read_cargo(&t));
112 let pyproject = read_optional(&path.join("pyproject.toml"))?.map(|t| read_pyproject(&t));
113 let package = match std::fs::read(path.join("package.json")) {
114 Ok(bytes) => Some(read_package_json(&bytes)),
115 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
116 Err(e) => return Err(RkError::Io(e)),
117 };
118 let (tech, manifest) = match (&cargo, &pyproject, &package) {
119 (Some(m), _, _) => (Some("rust"), m.clone()),
120 (None, Some(m), _) => (Some("python"), m.clone()),
121 (None, None, Some(m)) => (Some("node"), m.clone()),
122 (None, None, None) => (
123 path.join("VERSION").is_file().then_some("bash"),
124 Manifest::default(),
125 ),
126 };
127 let mut bins = manifest.bins.clone();
128 if tech == Some("rust") && bins.is_empty() && path.join("src/main.rs").is_file() {
129 bins.extend(manifest.name.clone());
130 }
131 let detected = crate::detect::detect(path.as_std_path());
132 let (host, owner_repo) = match (detected.host, detected.repo) {
133 (Some(host), Some(repo)) => (Some(host), Some(repo)),
134 _ => manifest
135 .repository
136 .as_deref()
137 .and_then(crate::detect::split_remote)
138 .map_or((None, None), |(host, repo)| (Some(host), Some(repo))),
139 };
140 let flake_package =
141 read_optional(&path.join("flake.nix"))?.is_some_and(|text| flake_serves_package(&text));
142 let tags = git_tags(&path);
143 let binstall_github =
144 binstall_resolves_to_github(manifest.binstall.as_ref(), owner_repo.as_deref());
145 let mut source = Source {
146 path,
147 tech,
148 name: manifest.name,
149 version: manifest.version,
150 bins,
151 owner_repo,
152 host,
153 flake_package,
154 dist_github: false,
155 binstall_github,
156 tag_style: tag_style(&tags),
157 channels: Vec::new(),
158 };
159 source.dist_github = read_optional(&source.path.join("dist-workspace.toml"))?
160 .is_some_and(|text| dist_hosts_on_github(&text));
161 source.channels = channels_of(&source);
162 Ok(source)
163}
164
165#[must_use]
167pub fn channels_of(source: &Source) -> Vec<ChannelEvidence> {
168 let mut out = Vec::new();
169 let named = source.name.is_some();
170 if named && source.tech == Some("rust") {
171 out.push(ChannelEvidence {
172 channel: Channel::Crates,
173 evidence: vec!["Cargo.toml names a package".into()],
174 });
175 }
176 if source.flake_package {
177 out.push(ChannelEvidence {
178 channel: Channel::Flake,
179 evidence: vec!["flake.nix serves a packages output".into()],
180 });
181 }
182 if named && source.tech == Some("python") {
183 out.push(ChannelEvidence {
184 channel: Channel::Pypi,
185 evidence: vec!["pyproject.toml names a project".into()],
186 });
187 }
188 if named && source.tech == Some("node") {
189 out.push(ChannelEvidence {
190 channel: Channel::Npm,
191 evidence: vec!["package.json names a package".into()],
192 });
193 }
194 let on_github = source.host.as_deref() == Some("github.com") && source.owner_repo.is_some();
195 if on_github && (source.dist_github || source.binstall_github) {
196 let mut evidence = Vec::new();
197 if source.dist_github {
198 evidence.push("dist-workspace.toml names github as its ci and hosting".into());
199 }
200 if source.binstall_github {
201 evidence.push("Cargo.toml binstall metadata resolves to GitHub releases".into());
202 }
203 out.push(ChannelEvidence {
204 channel: Channel::GithubRelease,
205 evidence,
206 });
207 }
208 out
209}
210
211#[must_use]
213pub fn read_cargo(text: &str) -> Manifest {
214 let Ok(table) = text.parse::<toml::Table>() else {
215 return Manifest::default();
216 };
217 let package = table.get("package").and_then(toml::Value::as_table);
218 let field = |key: &str| {
219 package
220 .and_then(|p| p.get(key))
221 .and_then(toml::Value::as_str)
222 .map(str::to_owned)
223 };
224 let bins = table
225 .get("bin")
226 .and_then(toml::Value::as_array)
227 .map(|bins| {
228 bins.iter()
229 .filter_map(|bin| bin.get("name").and_then(toml::Value::as_str))
230 .map(str::to_owned)
231 .collect()
232 })
233 .unwrap_or_default();
234 let binstall = package
235 .and_then(|p| p.get("metadata"))
236 .and_then(|m| m.get("binstall"))
237 .map(|binstall| {
238 binstall
239 .get("pkg-url")
240 .and_then(toml::Value::as_str)
241 .map(str::to_owned)
242 });
243 Manifest {
244 name: field("name"),
245 version: field("version"),
246 bins,
247 repository: field("repository"),
248 binstall,
249 }
250}
251
252#[must_use]
259pub fn binstall_resolves_to_github(
260 binstall: Option<&Option<String>>,
261 owner_repo: Option<&str>,
262) -> bool {
263 match binstall {
264 None => false,
265 Some(None) => true,
266 Some(Some(url)) => {
267 url.contains("/releases/download/")
268 && (url.contains("{ repo }")
269 || url.contains("{repo}")
270 || owner_repo.is_some_and(|repo| {
271 url.starts_with(&format!("https://github.com/{repo}/releases/download/"))
272 }))
273 }
274 }
275}
276
277#[must_use]
281pub fn dist_hosts_on_github(text: &str) -> bool {
282 let Ok(table) = text.parse::<toml::Table>() else {
283 return false;
284 };
285 let dist = table.get("dist").and_then(toml::Value::as_table);
286 let names = |key: &str| -> Option<Vec<String>> {
287 let value = dist?.get(key)?;
288 match value {
289 toml::Value::String(s) => Some(vec![s.clone()]),
290 toml::Value::Array(items) => Some(
291 items
292 .iter()
293 .filter_map(toml::Value::as_str)
294 .map(str::to_owned)
295 .collect(),
296 ),
297 _ => Some(Vec::new()),
298 }
299 };
300 let ci_github = names("ci").is_some_and(|ci| ci.iter().any(|c| c == "github"));
301 let hosting_github = names("hosting").is_none_or(|h| h.iter().any(|c| c == "github"));
302 ci_github && hosting_github
303}
304
305#[must_use]
308pub fn read_pyproject(text: &str) -> Manifest {
309 let Ok(table) = text.parse::<toml::Table>() else {
310 return Manifest::default();
311 };
312 let project = table.get("project").and_then(toml::Value::as_table);
313 let field = |key: &str| {
314 project
315 .and_then(|p| p.get(key))
316 .and_then(toml::Value::as_str)
317 .map(str::to_owned)
318 };
319 let bins = project
320 .and_then(|p| p.get("scripts"))
321 .and_then(toml::Value::as_table)
322 .map(|scripts| scripts.keys().cloned().collect())
323 .unwrap_or_default();
324 let repository = project
325 .and_then(|p| p.get("urls"))
326 .and_then(toml::Value::as_table)
327 .and_then(|urls| {
328 ["Repository", "repository", "Source", "source", "Homepage"]
329 .iter()
330 .find_map(|key| urls.get(*key))
331 })
332 .and_then(toml::Value::as_str)
333 .map(str::to_owned);
334 Manifest {
335 name: field("name"),
336 version: field("version"),
337 bins,
338 repository,
339 binstall: None,
340 }
341}
342
343#[must_use]
345pub fn read_package_json(bytes: &[u8]) -> Manifest {
346 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
347 return Manifest::default();
348 };
349 let text = |key: &str| value.get(key).and_then(|v| v.as_str()).map(str::to_owned);
350 let bins = match value.get("bin") {
351 Some(serde_json::Value::String(_)) => text("name").into_iter().collect(),
352 Some(serde_json::Value::Object(map)) => map.keys().cloned().collect(),
353 _ => Vec::new(),
354 };
355 let repository = match value.get("repository") {
356 Some(serde_json::Value::String(url)) => Some(url.clone()),
357 Some(serde_json::Value::Object(map)) => {
358 map.get("url").and_then(|v| v.as_str()).map(str::to_owned)
359 }
360 _ => None,
361 }
362 .map(|url| url.trim_start_matches("git+").to_owned());
363 Manifest {
364 name: text("name"),
365 version: text("version"),
366 bins,
367 repository,
368 binstall: None,
369 }
370}
371
372#[must_use]
380pub fn flake_serves_package(text: &str) -> bool {
381 let code = super::nix::scrub(text);
382 let bytes = code.as_bytes();
383 code.match_indices("packages").any(|(index, _)| {
384 let rest = &code[index + "packages".len()..];
385 if super::nix::is_default_package_path(&code, index) {
386 return true;
387 }
388 if index > 0 && (bytes[index - 1] == b'.' || super::nix::is_ident(bytes[index - 1])) {
389 return false;
390 }
391 let after = rest.trim_start();
392 let Some(value) = after.strip_prefix('=') else {
393 return false;
394 };
395 let value = super::nix::binding_value(value);
396 !value.trim_start().starts_with('[')
397 && super::nix::names_attribute(&super::nix::without_let_bindings(value), "default")
398 })
399}
400
401#[must_use]
404pub fn tag_style(tags: &[String]) -> TagStyle {
405 let prefixed = tags
406 .iter()
407 .filter(|tag| {
408 tag.strip_prefix('v')
409 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
410 })
411 .count();
412 let bare = tags
413 .iter()
414 .filter(|tag| tag.starts_with(|c: char| c.is_ascii_digit()))
415 .count();
416 match prefixed.cmp(&bare) {
417 std::cmp::Ordering::Greater => TagStyle::Prefixed,
418 std::cmp::Ordering::Less => TagStyle::Bare,
419 std::cmp::Ordering::Equal => TagStyle::Unknown,
420 }
421}
422
423fn git_tags(path: &Utf8Path) -> Vec<String> {
426 let mut command = Command::new(crate::probes::git_bin());
427 for var in crate::maintenance::GIT_HOOK_VARS {
428 command.env_remove(var);
429 }
430 let out = command
431 .arg("-C")
432 .arg(path.as_std_path())
433 .args(["tag", "--list"])
434 .output();
435 match out {
436 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
437 .lines()
438 .map(str::to_owned)
439 .collect(),
440 _ => Vec::new(),
441 }
442}
443
444fn read_optional(path: &Utf8Path) -> Result<Option<String>, RkError> {
446 match std::fs::read_to_string(path) {
447 Ok(text) => Ok(Some(text)),
448 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
449 Err(e) => Err(RkError::Io(e)),
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use camino::Utf8PathBuf;
456
457 use super::{
458 Channel, ChannelEvidence, Source, TagStyle, binstall_resolves_to_github, channels_of,
459 dist_hosts_on_github, flake_serves_package, read_cargo, read_package_json, read_pyproject,
460 tag_style,
461 };
462
463 fn sample(tech: Option<&'static str>) -> Source {
464 Source {
465 path: Utf8PathBuf::from("/srv/sample"),
466 tech,
467 name: Some("sample-tool".into()),
468 version: Some("1.4.0".into()),
469 bins: vec!["sam".into()],
470 owner_repo: Some("acme/sample-tool".into()),
471 host: Some("github.com".into()),
472 flake_package: false,
473 dist_github: false,
474 binstall_github: false,
475 tag_style: TagStyle::Prefixed,
476 channels: Vec::new(),
477 }
478 }
479
480 fn channels(source: &Source) -> Vec<Channel> {
481 channels_of(source)
482 .iter()
483 .map(|ChannelEvidence { channel, .. }| *channel)
484 .collect()
485 }
486
487 #[test]
488 fn a_cargo_manifest_yields_name_version_bins_and_repository() {
489 let manifest = read_cargo(
490 "[package]\nname = \"sample-tool\"\nversion = \"1.4.0\"\nrepository = \"https://github.com/acme/sample-tool\"\n\n[[bin]]\nname = \"sam\"\n\n[package.metadata.binstall]\npkg-url = \"x\"\n",
491 );
492 assert_eq!(manifest.name.as_deref(), Some("sample-tool"));
493 assert_eq!(manifest.version.as_deref(), Some("1.4.0"));
494 assert_eq!(manifest.bins, vec!["sam".to_owned()]);
495 assert_eq!(
496 manifest.repository.as_deref(),
497 Some("https://github.com/acme/sample-tool")
498 );
499 assert_eq!(manifest.binstall, Some(Some("x".to_owned())));
500 let default_table = read_cargo(
501 "[package]
502name = \"t\"
503version = \"1.0.0\"
504
505[package.metadata.binstall]
506pkg-fmt = \"tgz\"
507",
508 );
509 assert_eq!(default_table.binstall, Some(None));
510 assert_eq!(
511 read_cargo(
512 "[package]
513name = \"t\"
514"
515 )
516 .binstall,
517 None
518 );
519 let workspace = read_cargo("[workspace]\nmembers = [\"a\"]\n");
520 assert_eq!(workspace.name, None);
521 assert!(read_cargo("not = [toml").name.is_none());
522 }
523
524 #[test]
525 fn a_pyproject_yields_scripts_as_bins() {
526 let manifest = read_pyproject(
527 "[project]\nname = \"sample\"\nversion = \"2.0.0\"\n\n[project.scripts]\nsam = \"sample:main\"\n\n[project.urls]\nRepository = \"https://github.com/acme/sample\"\n",
528 );
529 assert_eq!(manifest.name.as_deref(), Some("sample"));
530 assert_eq!(manifest.bins, vec!["sam".to_owned()]);
531 assert_eq!(
532 manifest.repository.as_deref(),
533 Some("https://github.com/acme/sample")
534 );
535 }
536
537 #[test]
538 fn a_package_json_yields_bin_in_both_forms() {
539 let string_form = read_package_json(
540 br#"{"name":"sample","version":"3.1.0","bin":"cli.js","repository":"git+https://github.com/acme/sample.git"}"#,
541 );
542 assert_eq!(string_form.bins, vec!["sample".to_owned()]);
543 assert_eq!(
544 string_form.repository.as_deref(),
545 Some("https://github.com/acme/sample.git")
546 );
547 let map_form = read_package_json(
548 br#"{"name":"sample","version":"3.1.0","bin":{"sam":"cli.js"},"repository":{"type":"git","url":"https://github.com/acme/sample"}}"#,
549 );
550 assert_eq!(map_form.bins, vec!["sam".to_owned()]);
551 assert_eq!(map_form.version.as_deref(), Some("3.1.0"));
552 }
553
554 #[test]
555 fn a_flake_that_serves_packages_default_is_recognized() {
556 assert!(flake_serves_package(
557 "packages.default = pkgs.callPackage ./nix/package.nix { };"
558 ));
559 assert!(flake_serves_package(
560 "packages = eachSystem (pkgs: rec { tool = pkgs.callPackage ./nix/package.nix { }; default = tool; });"
561 ));
562 assert!(!flake_serves_package(
563 "devShells.default = pkgs.mkShell { packages = [ pkgs.just ]; };"
564 ));
565 assert!(
566 !flake_serves_package("# packages = disabled\noutputs = _: {};"),
567 "a comment is not code"
568 );
569 assert!(
570 !flake_serves_package(
571 "packages = eachSystem (pkgs: { tool = pkgs.callPackage ./nix/package.nix { }; });"
572 ),
573 "a packages set without a default serves no default"
574 );
575 assert!(
576 !flake_serves_package(
577 "packages = eachSystem (pkgs: { tool = pkgs.hello; });\n devShells.default = pkgs.mkShell { };"
578 ),
579 "a default after the binding closes is another binding's"
580 );
581 assert!(
582 !flake_serves_package("/* packages.default was here */ outputs = _: {};"),
583 "a block comment is not code"
584 );
585 assert!(
586 !flake_serves_package("description = \"packages.default\"; outputs = _: {};"),
587 "a string is not code"
588 );
589 assert!(
590 flake_serves_package("packages = let x = pkgs.hello; in { default = x; };"),
591 "a let binding's semicolons do not close the value"
592 );
593 assert!(
594 !flake_serves_package("packages = { notdefault = pkgs.hello; };"),
595 "default is matched as an attribute token"
596 );
597 assert!(
598 flake_serves_package("packages = with pkgs; { default = hello; };"),
599 "a with clause's semicolon does not close the value"
600 );
601 assert!(
602 flake_serves_package("packages = assert true; { default = pkgs.hello; };"),
603 "an assert clause's semicolon does not close the value"
604 );
605 assert!(
606 !flake_serves_package(
607 "packages = eachSystem (system: let default = pkgs.hello; in { tool = default; });"
608 ),
609 "a local let binding is not a default output"
610 );
611 assert!(flake_serves_package(
612 "packages.x86_64-linux.default = pkgs.hello;"
613 ));
614 assert!(
615 !flake_serves_package(
616 "devShells.default = pkgs.mkShell { packages = [ tool-input.packages.${system}.default ]; };"
617 ),
618 "a devshell consuming another input's package exports none"
619 );
620 assert!(
621 !flake_serves_package("mypackages.${system}.default = x;"),
622 "a longer identifier is not the packages output"
623 );
624 assert!(!flake_serves_package(
625 "my_packages = { default = pkgs.hello; };"
626 ));
627 assert!(!flake_serves_package(
628 "my-packages = { default = pkgs.hello; };"
629 ));
630 assert!(flake_serves_package(
631 "packages.${system}.default = pkgs.hello;"
632 ));
633 assert!(!flake_serves_package(
634 "packages.x86_64-linux.tool = pkgs.hello;"
635 ));
636 assert!(
637 !flake_serves_package(
638 "packages = { tool = with pkgs; hello; };\n devShells.default = pkgs.mkShell { };"
639 ),
640 "a nested with clause does not leak the binding boundary"
641 );
642 assert!(!flake_serves_package("{ inputs = {}; outputs = _: {}; }"));
643 }
644
645 #[test]
646 fn binstall_counts_only_as_a_release_asset_of_this_repository() {
647 let repo = Some("acme/sample-tool");
648 assert!(!binstall_resolves_to_github(None, repo));
649 assert!(
650 binstall_resolves_to_github(Some(&None), repo),
651 "the GitHub default"
652 );
653 let url = |u: &str| Some(u.to_owned());
654 assert!(binstall_resolves_to_github(
655 Some(&url(
656 "{ repo }/releases/download/v{ version }/{ name }-{ target }.tgz"
657 )),
658 repo
659 ));
660 assert!(binstall_resolves_to_github(
661 Some(&url(
662 "https://github.com/acme/sample-tool/releases/download/v{ version }/x.tgz"
663 )),
664 repo
665 ));
666 assert!(
667 !binstall_resolves_to_github(
668 Some(&url("{ repo }/archive/refs/tags/v{ version }.tar.gz")),
669 repo
670 ),
671 "a source archive is not a release asset"
672 );
673 assert!(
674 !binstall_resolves_to_github(
675 Some(&url(
676 "https://raw.githubusercontent.com/acme/sample-tool/main/x.tgz"
677 )),
678 repo
679 ),
680 "raw content is not a release asset"
681 );
682 assert!(
683 !binstall_resolves_to_github(
684 Some(&url(
685 "https://github.com/other/thing/releases/download/v1/x.tgz"
686 )),
687 repo
688 ),
689 "another repository's release is not this source's"
690 );
691 assert!(
692 !binstall_resolves_to_github(
693 Some(&url(
694 "https://github.com/acme/sample-tool-fork/releases/download/v1/x.tgz"
695 )),
696 repo
697 ),
698 "a repository that merely starts with this one's path is another repository"
699 );
700 assert!(
701 !binstall_resolves_to_github(
702 Some(&url(
703 "https://example.org/github.com/acme/sample-tool/releases/download/v1/x.tgz"
704 )),
705 repo
706 ),
707 "an embedded host is another host"
708 );
709 assert!(!binstall_resolves_to_github(
710 Some(&url("https://dl.example.org/{ name }-{ version }.tgz")),
711 repo
712 ));
713 }
714
715 #[test]
716 fn dist_hosting_is_read_from_the_dist_table() {
717 assert!(dist_hosts_on_github("[dist]\nci = \"github\"\n"));
718 assert!(dist_hosts_on_github(
719 "[dist]\nci = [\"github\"]\nhosting = [\"github\", \"axodotdev\"]\n"
720 ));
721 assert!(!dist_hosts_on_github(
722 "[dist]\nci = \"github\"\nhosting = \"axodotdev\"\n"
723 ));
724 assert!(!dist_hosts_on_github(
725 "[dist]\ntargets = [\"x86_64-unknown-linux-gnu\"]\n"
726 ));
727 assert!(!dist_hosts_on_github("not = [toml"));
728 }
729
730 #[test]
731 fn the_tag_style_is_read_from_the_tags() {
732 let owned = |tags: &[&str]| tags.iter().map(|t| (*t).to_owned()).collect::<Vec<_>>();
733 assert_eq!(tag_style(&owned(&["v1.0.0", "v1.1.0"])), TagStyle::Prefixed);
734 assert_eq!(tag_style(&owned(&["1.0.0", "1.1.0"])), TagStyle::Bare);
735 assert_eq!(tag_style(&owned(&["v1.0.0", "1.1.0"])), TagStyle::Unknown);
736 assert_eq!(tag_style(&owned(&["release", "rc"])), TagStyle::Unknown);
737 assert_eq!(tag_style(&[]), TagStyle::Unknown);
738 }
739
740 #[test]
742 fn the_channels_follow_the_evidence() {
743 let mut rust = sample(Some("rust"));
744 assert_eq!(channels(&rust), [Channel::Crates]);
745 rust.dist_github = true;
746 rust.flake_package = true;
747 assert_eq!(
748 channels(&rust),
749 [Channel::Crates, Channel::Flake, Channel::GithubRelease]
750 );
751 rust.host = Some("codeberg.org".into());
752 assert_eq!(
753 channels(&rust),
754 [Channel::Crates, Channel::Flake],
755 "release archives are a GitHub fact"
756 );
757 assert_eq!(channels(&sample(Some("python"))), [Channel::Pypi]);
758 assert_eq!(channels(&sample(Some("node"))), [Channel::Npm]);
759 let mut bare = sample(None);
760 bare.name = None;
761 assert!(channels(&bare).is_empty());
762 }
763}