Skip to main content

release_kit/depend/
source.rs

1//! What the dependency's checkout declares about its distribution.
2//!
3//! Every signal is a file in the source tree or its git remote and tags:
4//! a manifest names the package and its version, `dist-workspace.toml`
5//! says release archives are built, a flake that serves `packages`
6//! says the repository is a flake input. No registry is asked whether
7//! the version is published; the assessment is offline.
8
9use std::process::Command;
10
11use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use super::{Channel, canonical_dir};
15use crate::error::RkError;
16
17/// Which shape the source's release tags take.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum TagStyle {
21    /// `v1.2.3`.
22    Prefixed,
23    /// `1.2.3`.
24    Bare,
25    /// No tag, or no majority: the prefixed form is assumed.
26    Unknown,
27}
28
29/// One channel and the evidence behind it.
30#[derive(Debug, Clone, Serialize)]
31pub struct ChannelEvidence {
32    /// The channel.
33    pub channel: Channel,
34    /// The files and facts that make it viable.
35    pub evidence: Vec<String>,
36}
37
38/// What one manifest declares, whatever its format.
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct Manifest {
41    /// The package name.
42    pub name: Option<String>,
43    /// The declared version.
44    pub version: Option<String>,
45    /// The executables it installs.
46    pub bins: Vec<String>,
47    /// The repository URL it names.
48    pub repository: Option<String>,
49    /// `[package.metadata.binstall]`: `None` where absent, `Some(None)`
50    /// for a table with no `pkg-url`, `Some(Some(url))` for its template.
51    pub binstall: Option<Option<String>>,
52}
53
54/// Everything the offline pass reads from the source.
55#[derive(Debug, Clone)]
56pub struct Source {
57    /// The checkout, canonical.
58    pub path: Utf8PathBuf,
59    /// `rust`, `python`, `node`, or `bash`, where a manifest says.
60    pub tech: Option<&'static str>,
61    /// The package name.
62    pub name: Option<String>,
63    /// The declared version.
64    pub version: Option<String>,
65    /// The executables the package installs.
66    pub bins: Vec<String>,
67    /// `owner/repo`, from the remote or the manifest.
68    pub owner_repo: Option<String>,
69    /// The remote's host.
70    pub host: Option<String>,
71    /// Whether `flake.nix` serves a package output.
72    pub flake_package: bool,
73    /// Whether `dist-workspace.toml` declares GitHub as its CI and does
74    /// not send hosting elsewhere.
75    pub dist_github: bool,
76    /// Whether the manifest carries binstall metadata whose package URL
77    /// is the GitHub release default, or names GitHub.
78    pub binstall_github: bool,
79    /// The shape of the release tags.
80    pub tag_style: TagStyle,
81    /// The viable channels, in the closed order.
82    pub channels: Vec<ChannelEvidence>,
83}
84
85impl Source {
86    /// The executable to name for a prebuilt archive: the first declared
87    /// bin, else the package name.
88    #[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    /// Whether a channel is viable.
97    #[must_use]
98    pub fn has(&self, channel: Channel) -> bool {
99        self.channels.iter().any(|c| c.channel == channel)
100    }
101}
102
103/// Read the source, offline.
104///
105/// # Errors
106///
107/// Returns [`RkError::Missing`] for a path that is not a directory and
108/// [`RkError::Io`] where a present file does not read.
109pub 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/// The channels the evidence supports, in the closed order.
166#[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/// What a `Cargo.toml` declares: the package, its bins, its repository.
212#[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/// Whether binstall metadata resolves to this repository's GitHub
253/// releases.
254///
255/// A table with no `pkg-url` takes cargo-binstall's GitHub default, and
256/// a custom `pkg-url` counts only under `/releases/download/` of the
257/// `{ repo }` template or of this repository's own path on `github.com`.
258#[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/// Whether a `dist-workspace.toml` hosts its releases on GitHub: `ci`
278/// names `github`, and `hosting`, where set, still names it. A file
279/// that does not parse or names another CI is not evidence.
280#[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/// What a `pyproject.toml` declares: the project, its scripts, its
306/// repository URL.
307#[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/// What a `package.json` declares: the package, its bins, its repository.
344#[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/// Whether a flake serves a default package output, judged lexically.
373///
374/// Comments and string contents are scrubbed first. The evidence is a
375/// `packages.default` or `packages.<system>.default` attribute path, or
376/// a `packages =` binding whose
377/// own value, up to its terminating `;`, is not a `mkShell` list and
378/// binds a `default` attribute.
379#[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/// The tag shape a tag list shows: the majority form, `Unknown` on a tie
402/// or an empty list.
403#[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
423/// The source's tags, or none where it is not a repository: an
424/// observation, never a failure, because the tag style has a default.
425fn 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
444/// A file's text, or `None` where it does not exist.
445fn 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    /// SATISFIES dependencies:the-channel-follows-the-source-evidence
741    #[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}