use serde::Serialize;
use super::pin::PIN_PREFIX;
use super::{Observed, pin};
use crate::embedded::BLOCKS;
const PIN_TOKEN: &str = "RK_DEVSHELL_PIN";
#[derive(Debug, Clone, Serialize)]
pub struct Fragment {
pub id: &'static str,
pub file: &'static str,
pub role: &'static str,
pub placement: &'static str,
pub anchor: Anchor,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub present: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Anchor {
pub kind: &'static str,
pub path: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub needle: Option<&'static str>,
}
#[must_use]
pub fn fragments(tag: &str, observed: &Observed) -> Vec<Fragment> {
let flake = observed.flake_text.as_deref();
let envrc_present = observed.envrc.is_present();
vec![
Fragment {
id: "flake-input",
file: "flake.nix",
role: "the pinned release-kit input",
placement: "insert-into-attrset",
anchor: Anchor {
kind: "attrset",
path: "inputs",
needle: flake.and_then(|text| first_found(text, &["inputs = {", "inputs ="])),
},
text: fragment("devshell-input.nix.in", tag),
present: Some(flake.is_some() && !matches!(observed.scan, pin::Scan::None)),
},
Fragment {
id: "outputs-argument",
file: "flake.nix",
role: "the release-kit argument of the outputs function",
placement: "add-to-function-head",
anchor: Anchor {
kind: "function-head",
path: "outputs",
needle: flake.and_then(|text| first_found(text, &["outputs =", "outputs"])),
},
text: fragment("devshell-outputs-arg.nix.in", tag),
present: flake.map_or(Some(false), outputs_argument_present),
},
Fragment {
id: "devshell-package",
file: "flake.nix",
role: "the rk package in the default devshell",
placement: "append-to-list",
anchor: Anchor {
kind: "list",
path: "devShells.<system>.default.packages",
needle: flake.and_then(|text| first_found(text, &["packages = [", "devShells"])),
},
text: fragment("devshell-package.nix.in", tag),
present: flake.map_or(Some(false), devshell_package_present),
},
Fragment {
id: "envrc-sync",
file: ".envrc",
role: "the daily sync on directory entry",
placement: "append-line",
anchor: Anchor {
kind: "file",
path: ".envrc",
needle: None,
},
text: envrc_line(),
present: Some(envrc_present && observed.envrc_sync),
},
]
}
#[must_use]
pub fn seed_flake(tag: &str) -> String {
render(block("devshell-seed-flake.nix.in"), tag)
}
#[must_use]
pub fn seed_envrc() -> String {
block("devshell-seed-envrc.in").to_owned()
}
#[must_use]
pub fn envrc_line() -> String {
block("devshell-envrc-line.in")
.trim_end_matches('\n')
.to_owned()
}
#[must_use]
pub fn pinned_url(tag: &str) -> String {
format!("{PIN_PREFIX}{tag}")
}
fn render(text: &str, tag: &str) -> String {
text.replace(PIN_TOKEN, &pinned_url(tag))
}
fn fragment(name: &str, tag: &str) -> String {
render(block(name), tag).trim_end_matches('\n').to_owned()
}
fn block(name: &str) -> &'static str {
BLOCKS
.get_file(name)
.and_then(|file| file.contents_utf8())
.unwrap_or_default()
}
fn first_found(text: &str, needles: &[&'static str]) -> Option<&'static str> {
needles.iter().copied().find(|needle| text.contains(needle))
}
fn outputs_argument_present(text: &str) -> Option<bool> {
let start = text.find("outputs")?;
let rest = &text[start + "outputs".len()..];
let head = &rest[..rest.find(':')?];
if head.contains("release-kit") {
return Some(true);
}
if head.contains("...") || head.contains('@') || !head.contains('{') {
return None;
}
Some(false)
}
fn devshell_package_present(text: &str) -> Option<bool> {
let package = block("devshell-package.nix.in").trim_end_matches('\n');
let prefix = package.split("${").next().unwrap_or(package);
if text.contains(prefix) {
return Some(true);
}
text.contains("devShells").then_some(false)
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::panic)]
use super::{
PIN_TOKEN, block, devshell_package_present, envrc_line, fragment, outputs_argument_present,
render, seed_envrc, seed_flake,
};
use crate::devshell::pin::{PIN_PREFIX, Scan, scan};
#[test]
fn the_pin_matcher_matches_the_authored_input_fragment() {
let text = fragment("devshell-input.nix.in", "v0.2.16");
match scan(&text) {
Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
other => panic!("the fragment must scan as one pin: {other:?}"),
}
match scan(&seed_flake("v0.2.16")) {
Scan::One(pin) => assert_eq!(pin.tag, "v0.2.16"),
other => panic!("the seed must scan as one pin: {other:?}"),
}
}
#[test]
fn every_fragment_renders_its_tag_and_keeps_the_system_interpolation() {
for name in [
"devshell-input.nix.in",
"devshell-outputs-arg.nix.in",
"devshell-package.nix.in",
"devshell-envrc-line.in",
"devshell-seed-flake.nix.in",
"devshell-seed-envrc.in",
] {
let rendered = render(block(name), "v9.9.9");
assert!(!rendered.contains(PIN_TOKEN), "{name}: the token renders");
assert!(!rendered.is_empty(), "{name}: the block is authored");
}
let package = fragment("devshell-package.nix.in", "v9.9.9");
assert_eq!(package, "release-kit.packages.${system}.default");
let seed = seed_flake("v9.9.9");
assert!(seed.contains("${system}"), "the interpolation survives");
assert!(seed.contains(&format!("{PIN_PREFIX}v9.9.9")));
assert!(seed.ends_with("}\n"), "a seed file keeps its final newline");
assert!(seed_envrc().ends_with('\n'));
assert!(
!envrc_line().ends_with('\n'),
"a fragment carries no newline"
);
assert!(seed_envrc().ends_with(&format!("{}\n", envrc_line())));
}
#[test]
fn the_outputs_head_is_judged_lexically() {
assert_eq!(
outputs_argument_present("outputs = { self, nixpkgs, release-kit }: {}"),
Some(true)
);
assert_eq!(
outputs_argument_present("outputs =\n { self, nixpkgs }:\n {}"),
Some(false)
);
assert_eq!(
outputs_argument_present("outputs = { self, ... }: {}"),
None,
"an ellipsis binds the input another way"
);
assert_eq!(outputs_argument_present("outputs = inputs: {}"), None);
assert_eq!(outputs_argument_present("{ inputs = {}; }"), None);
}
#[test]
fn the_devshell_package_is_judged_lexically() {
assert_eq!(
devshell_package_present(
"devShells = { default = mkShell { packages = [ release-kit.packages.${system}.default ]; }; }"
),
Some(true)
);
assert_eq!(
devshell_package_present("devShells = { default = mkShell { packages = [ just ]; }; }"),
Some(false)
);
assert_eq!(devshell_package_present("packages = {}"), None);
}
}