use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
#[gen_macros::fsm(label = "gen.flake-lint.issue")]
pub enum FlakeIssue {
StaleFollowsOverride {
consumer: String,
target: String,
},
StaleInternalPin {
input: String,
rev: String,
line: u32,
},
}
pub trait MetadataSource: Send + Sync {
fn metadata(&self, flake_path: &Path) -> std::io::Result<String>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NixCliMetadataSource;
impl MetadataSource for NixCliMetadataSource {
fn metadata(&self, flake_path: &Path) -> std::io::Result<String> {
let out = Command::new("nix")
.args(["flake", "metadata", "--no-write-lock-file"])
.current_dir(flake_path)
.output()?;
let mut combined = String::from_utf8_lossy(&out.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&out.stderr));
Ok(combined)
}
}
#[must_use]
pub fn parse_metadata_output(text: &str) -> Vec<FlakeIssue> {
let mut issues = Vec::new();
for line in text.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("warning: input '") else {
continue;
};
let Some((consumer, rest)) = rest.split_once('\'') else {
continue;
};
let Some(rest) = rest.strip_prefix(" has an override for a non-existent input '") else {
continue;
};
let Some((target, _)) = rest.split_once('\'') else {
continue;
};
issues.push(FlakeIssue::StaleFollowsOverride {
consumer: consumer.to_string(),
target: target.to_string(),
});
}
issues
}
pub fn apply_fix(issues: &[FlakeIssue], flake_path: &Path) -> std::io::Result<usize> {
let text = std::fs::read_to_string(flake_path)?;
let (new_text, removed) = rewrite(&text, issues);
if new_text != text {
std::fs::write(flake_path, new_text)?;
}
Ok(removed)
}
#[must_use]
pub fn parse_flake_nix_pins(text: &str) -> Vec<FlakeIssue> {
let mut out = Vec::new();
let prefix = "github:pleme-io/";
for (i, line) in text.lines().enumerate() {
let line_trim = line.trim();
if !line_trim.contains("url") || !line_trim.contains(prefix) {
continue;
}
let Some(quote_open) = line.find(prefix) else { continue; };
let after = &line[quote_open + prefix.len()..];
let Some(quote_close) = after.find('"') else { continue; };
let url_body = &after[..quote_close];
let Some(slash) = url_body.find('/') else { continue; };
let input = &url_body[..slash];
let rev_raw = &url_body[slash + 1..];
let rev = rev_raw.split('?').next().unwrap_or(rev_raw);
if rev.len() < 6 || rev.len() > 40 || !rev.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
out.push(FlakeIssue::StaleInternalPin {
input: input.to_string(),
rev: rev.to_string(),
line: (i + 1) as u32,
});
}
out
}
#[must_use]
pub fn rewrite(text: &str, issues: &[FlakeIssue]) -> (String, usize) {
let mut to_remove: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
let mut to_depin: std::collections::HashSet<(String, String)> =
std::collections::HashSet::new();
for i in issues {
match i {
FlakeIssue::StaleFollowsOverride { consumer, target } => {
to_remove.insert((consumer.clone(), target.clone()));
}
FlakeIssue::StaleInternalPin { input, rev, .. } => {
to_depin.insert((input.clone(), rev.clone()));
}
}
}
let mut out = String::with_capacity(text.len());
let mut current_consumer: Option<String> = None;
let mut removed = 0;
for line in text.lines() {
let trimmed = line.trim_start();
if let Some(name) = parse_block_header(trimmed) {
current_consumer = Some(name);
out.push_str(line);
out.push('\n');
continue;
}
if trimmed == "};" || trimmed == "}" {
current_consumer = None;
out.push_str(line);
out.push('\n');
continue;
}
if let Some(consumer) = current_consumer.as_ref() {
if let Some(target) = parse_follows_target(trimmed) {
if to_remove.contains(&(consumer.clone(), target.clone())) {
removed += 1;
continue;
}
}
}
let mut depinned_line = line.to_string();
let mut line_changed = false;
for (input, rev) in &to_depin {
let needle = format!("github:pleme-io/{input}/{rev}");
let replacement = format!("github:pleme-io/{input}");
if depinned_line.contains(&needle) {
depinned_line = depinned_line.replace(&needle, &replacement);
line_changed = true;
}
}
if line_changed {
removed += 1;
}
out.push_str(&depinned_line);
out.push('\n');
}
(out, removed)
}
fn parse_block_header(trimmed: &str) -> Option<String> {
let rest = trimmed.strip_suffix('{')?;
let rest = rest.trim_end();
let rest = rest.strip_suffix('=')?;
let name = rest.trim();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
return None;
}
Some(name.to_string())
}
fn parse_follows_target(trimmed: &str) -> Option<String> {
let rest = trimmed.strip_prefix("inputs.")?;
let (target, after) = rest.split_once('.')?;
if !after.starts_with("follows") {
return None;
}
Some(target.to_string())
}
pub fn run<S: MetadataSource>(
source: &S,
flake_path: &Path,
fix: bool,
) -> std::io::Result<FlakeLintReport> {
let text = source.metadata(flake_path)?;
let mut issues = parse_metadata_output(&text);
let flake_nix = flake_path.join("flake.nix");
if flake_nix.exists() {
let flake_text = std::fs::read_to_string(&flake_nix)?;
issues.extend(parse_flake_nix_pins(&flake_text));
}
let mut fixed = 0usize;
if fix && !issues.is_empty() {
fixed = apply_fix(&issues, &flake_nix)?;
}
Ok(FlakeLintReport {
flake_path: flake_path.to_path_buf(),
issues,
fixed_lines: fixed,
})
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FlakeLintReport {
pub flake_path: PathBuf,
pub issues: Vec<FlakeIssue>,
pub fixed_lines: usize,
}
#[cfg(test)]
mod tests {
use super::*;
struct StaticMetadataSource(&'static str);
impl MetadataSource for StaticMetadataSource {
fn metadata(&self, _: &Path) -> std::io::Result<String> {
Ok(self.0.to_string())
}
}
#[test]
fn parse_metadata_output_extracts_each_warning() {
let txt = "\
warning: input 'seibi' has an override for a non-existent input 'fenix'
warning: input 'seibi' has an override for a non-existent input 'nixpkgs'
warning: input 'kontena' has an override for a non-existent input 'nixpkgs'
warning: Git tree is dirty
";
let issues = parse_metadata_output(txt);
assert_eq!(issues.len(), 3);
assert_eq!(
issues[0],
FlakeIssue::StaleFollowsOverride {
consumer: "seibi".into(),
target: "fenix".into()
}
);
assert_eq!(
issues[2],
FlakeIssue::StaleFollowsOverride {
consumer: "kontena".into(),
target: "nixpkgs".into()
}
);
}
#[test]
fn parse_metadata_output_ignores_unrelated_warnings() {
let txt = "warning: Git tree is dirty\nwarning: some other thing\n";
assert!(parse_metadata_output(txt).is_empty());
}
#[test]
fn rewrite_removes_only_targeted_follows_inside_consumer_block() {
let text = r#"{
inputs = {
seibi = {
url = "github:pleme-io/seibi";
inputs.nixpkgs.follows = "nixpkgs";
inputs.fenix.follows = "fenix";
inputs.substrate.follows = "substrate";
};
other = {
inputs.fenix.follows = "fenix";
};
};
}
"#;
let issues = vec![
FlakeIssue::StaleFollowsOverride {
consumer: "seibi".into(),
target: "fenix".into(),
},
FlakeIssue::StaleFollowsOverride {
consumer: "seibi".into(),
target: "nixpkgs".into(),
},
];
let (out, removed) = rewrite(text, &issues);
assert_eq!(removed, 2);
assert!(out.contains(r#"inputs.substrate.follows = "substrate";"#));
assert!(!out.contains("seibi.*inputs.nixpkgs.follows"));
assert!(!out.contains("seibi.*inputs.fenix.follows"));
assert!(out.contains(r#"other = {
inputs.fenix.follows = "fenix";
};"#));
}
#[test]
fn rewrite_is_idempotent_when_no_issues() {
let text = "seibi = {\n inputs.nixpkgs.follows = \"nixpkgs\";\n};\n";
let (out, removed) = rewrite(text, &[]);
assert_eq!(removed, 0);
assert_eq!(out, text);
}
#[test]
fn parse_flake_nix_pins_detects_rev_pinned_urls() {
let text = r#"
{
inputs = {
substrate = {
url = "github:pleme-io/substrate/3594ae2dce08";
};
nixpkgs.url = "github:nixos/nixpkgs/main";
blackmatter-kubernetes = {
url = "github:pleme-io/blackmatter-kubernetes/99ba10a";
};
upstream.url = "github:some-other-org/repo/deadbeef";
};
}
"#;
let issues = parse_flake_nix_pins(text);
assert_eq!(issues.len(), 2, "expected 2 pleme-io pins, got: {:?}", issues);
let names: Vec<_> = issues
.iter()
.filter_map(|i| match i {
FlakeIssue::StaleInternalPin { input, .. } => Some(input.as_str()),
_ => None,
})
.collect();
assert!(names.contains(&"substrate"));
assert!(names.contains(&"blackmatter-kubernetes"));
}
#[test]
fn parse_flake_nix_pins_ignores_non_pleme_io_urls() {
let text = r#"
{
inputs.upstream.url = "github:some-other-org/repo/deadbeefcafebabe";
inputs.nixpkgs.url = "github:nixos/nixpkgs/main";
}
"#;
assert!(parse_flake_nix_pins(text).is_empty());
}
#[test]
fn rewrite_depins_internal_url_only_when_issue_targets_it() {
let text = r#"
{
inputs = {
substrate.url = "github:pleme-io/substrate/3594ae2";
untouched.url = "github:pleme-io/untouched/abcdef0";
};
}
"#;
let issues = vec![FlakeIssue::StaleInternalPin {
input: "substrate".into(),
rev: "3594ae2".into(),
line: 4,
}];
let (out, removed) = rewrite(text, &issues);
assert_eq!(removed, 1);
assert!(out.contains(r#"substrate.url = "github:pleme-io/substrate";"#));
assert!(out.contains(r#"untouched.url = "github:pleme-io/untouched/abcdef0";"#));
}
#[test]
fn flake_issue_now_has_two_variants() {
use gen_platform::TypedDispatcherTrait;
assert_eq!(<FlakeIssue as TypedDispatcherTrait>::variant_count(), 2);
let kinds = <FlakeIssue as TypedDispatcherTrait>::variant_kinds();
assert!(kinds.contains(&"stale-follows-override"));
assert!(kinds.contains(&"stale-internal-pin"));
}
#[test]
fn run_with_mock_source_reports_and_optionally_fixes() {
let src = StaticMetadataSource(
"warning: input 'seibi' has an override for a non-existent input 'fenix'\n",
);
let tmp = std::env::temp_dir().join(format!(
"gen-flake-lint-test-{}",
std::process::id()
));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("flake.nix"),
"seibi = {\n inputs.fenix.follows = \"fenix\";\n};\n",
)
.unwrap();
let report = run(&src, &tmp, false).unwrap();
assert_eq!(report.issues.len(), 1);
assert_eq!(report.fixed_lines, 0);
let report2 = run(&src, &tmp, true).unwrap();
assert_eq!(report2.issues.len(), 1);
assert_eq!(report2.fixed_lines, 1);
let fixed = std::fs::read_to_string(tmp.join("flake.nix")).unwrap();
assert!(!fixed.contains("inputs.fenix.follows"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn flake_issue_typed_dispatcher_variant_count_is_two() {
use gen_platform::TypedDispatcherTrait;
assert_eq!(<FlakeIssue as TypedDispatcherTrait>::variant_count(), 2);
}
#[test]
fn flake_issue_is_variant_helpers_work() {
let i = FlakeIssue::StaleFollowsOverride {
consumer: "x".into(),
target: "y".into(),
};
assert!(i.is_stale_follows_override());
}
#[test]
fn flake_issue_summary_matches_discriminant() {
let i = FlakeIssue::StaleFollowsOverride {
consumer: "x".into(),
target: "y".into(),
};
assert_eq!(i.discriminant(), "stale-follows-override");
}
}