use std::collections::BTreeMap;
use serde::Serialize;
use crate::cli::ListFormat;
use crate::edit::{FlakeEdit, InputMap, sorted_input_ids};
use crate::input::Follows;
use super::Result;
pub fn list(flake_edit: &mut FlakeEdit, format: &ListFormat) -> Result<()> {
let inputs = flake_edit.list();
list_inputs(inputs, format);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ListOutput {
pub inputs: BTreeMap<String, InputView>,
pub follows: Vec<FollowEdge>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct InputView {
pub id: String,
pub url: String,
pub flake: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FollowEdge {
pub parent: String,
pub nested: String,
pub target: String,
pub kind: FollowEdgeKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum FollowEdgeKind {
Indirect,
Direct,
}
impl From<&InputMap> for ListOutput {
fn from(inputs: &InputMap) -> Self {
let mut input_views: BTreeMap<String, InputView> = BTreeMap::new();
let mut follows: Vec<FollowEdge> = Vec::new();
for key in sorted_input_ids(inputs) {
let input = &inputs[key];
let parent_id = input.id().as_str().to_string();
input_views.insert(
key.clone(),
InputView {
id: parent_id.clone(),
url: input.url().to_string(),
flake: input.flake,
},
);
for f in input.follows() {
match f {
Follows::Indirect { path, target } => {
follows.push(FollowEdge {
parent: parent_id.clone(),
nested: path.to_string(),
target: target
.as_ref()
.map(|t| t.to_flake_follows_string())
.unwrap_or_default(),
kind: FollowEdgeKind::Indirect,
});
}
Follows::Direct(name, child) => {
follows.push(FollowEdge {
parent: parent_id.clone(),
nested: name.clone(),
target: child.url().to_string(),
kind: FollowEdgeKind::Direct,
});
}
}
}
}
ListOutput {
inputs: input_views,
follows,
}
}
}
pub(super) fn list_inputs(inputs: &InputMap, format: &ListFormat) {
match format {
ListFormat::Simple => list_simple(inputs),
ListFormat::Json => list_json(inputs),
ListFormat::Detailed => list_detailed(inputs),
ListFormat::Toplevel => list_toplevel(inputs),
}
}
fn list_simple(inputs: &InputMap) {
let mut buf = String::new();
for key in sorted_input_ids(inputs) {
let input = &inputs[key];
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(input.id().as_str());
for follows in input.follows() {
if let Follows::Indirect { path, .. } = follows {
let id = format!("{}.{}", input.id().as_str(), path);
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(&id);
}
}
}
println!("{buf}");
}
fn list_json(inputs: &InputMap) {
let out: ListOutput = inputs.into();
println!("{}", serde_json::to_string(&out).unwrap());
}
fn list_toplevel(inputs: &InputMap) {
let mut buf = String::new();
for key in sorted_input_ids(inputs) {
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(&key.to_string());
}
println!("{buf}");
}
fn is_toplevel_follows(url: &str) -> bool {
!url.is_empty() && !url.contains(':') && url.contains('/') && !url.starts_with('/')
}
fn list_detailed(inputs: &InputMap) {
let mut buf = String::new();
for key in sorted_input_ids(inputs) {
let input = &inputs[key];
if !buf.is_empty() {
buf.push('\n');
}
let line = if is_toplevel_follows(input.url()) {
format!("· {} <= {}", input.id().as_str(), input.url())
} else {
format!("· {} - {}", input.id().as_str(), input.url())
};
buf.push_str(&line);
for follows in input.follows() {
if let Follows::Indirect { path, target } = follows {
let target_str = match target {
Some(t) => t.to_flake_follows_string(),
None => "\"\"".to_string(),
};
let id = format!("{}{} => {}", " ".repeat(5), path, target_str);
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(&id);
}
}
}
println!("{buf}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::edit::FlakeEdit;
use crate::follows::{AttrPath, Segment};
use crate::input::{Follows, Input, Range};
use serde_json::json;
#[test]
fn list_output_empty_inputs_is_empty_shape() {
let inputs: InputMap = InputMap::new();
let out: ListOutput = (&inputs).into();
let v = serde_json::to_value(&out).unwrap();
assert_eq!(v, json!({ "inputs": {}, "follows": [] }));
}
#[test]
fn list_output_single_toplevel_no_follows() {
let mut inputs = InputMap::new();
let id = Segment::from_unquoted("nixpkgs").unwrap();
let mut input = Input::new(id);
input.url = "github:nixos/nixpkgs/nixos-unstable".into();
inputs.insert("nixpkgs".into(), input);
let v = serde_json::to_value(ListOutput::from(&inputs)).unwrap();
assert_eq!(
v,
json!({
"inputs": {
"nixpkgs": {
"id": "nixpkgs",
"url": "github:nixos/nixpkgs/nixos-unstable",
"flake": true,
}
},
"follows": [],
})
);
}
#[test]
fn list_output_renders_indirect_follows_as_flat_array() {
let mut inputs = InputMap::new();
let crane = Segment::from_unquoted("crane").unwrap();
let mut input = Input::new(crane);
input.url = "github:ipetkov/crane".into();
input.range = Range {
start: 100,
end: 120,
};
input.follows.push(Follows::Indirect {
path: AttrPath::new(Segment::from_unquoted("nixpkgs").unwrap()),
target: Some(AttrPath::parse("nixpkgs").unwrap()),
});
inputs.insert("crane".into(), input);
let v = serde_json::to_value(ListOutput::from(&inputs)).unwrap();
assert_eq!(
v,
json!({
"inputs": {
"crane": {
"id": "crane",
"url": "github:ipetkov/crane",
"flake": true,
}
},
"follows": [
{
"parent": "crane",
"nested": "nixpkgs",
"target": "nixpkgs",
"kind": "indirect"
}
],
})
);
}
#[test]
fn list_output_url_is_unquoted() {
let mut inputs = InputMap::new();
let id = Segment::from_unquoted("nixpkgs").unwrap();
let mut input = Input::new(id);
input.url = "github:nixos/nixpkgs".into();
inputs.insert("nixpkgs".into(), input);
let s = serde_json::to_string(&ListOutput::from(&inputs)).unwrap();
assert!(
!s.contains("\\\"github:"),
"URL was double-quoted in JSON output: {s}",
);
assert!(
s.contains("\"url\":\"github:nixos/nixpkgs\""),
"expected unquoted url field in JSON output: {s}",
);
}
#[test]
fn list_output_kind_serialises_kebab_case() {
let edge = FollowEdge {
parent: "a".into(),
nested: "b".into(),
target: "c".into(),
kind: FollowEdgeKind::Indirect,
};
let v = serde_json::to_value(&edge).unwrap();
assert_eq!(v.get("kind").unwrap(), &json!("indirect"));
}
#[test]
fn list_output_inputs_sorted_by_id() {
let content = r#"{
inputs.zzz.url = "github:ex/zzz";
inputs.aaa.url = "github:ex/aaa";
outputs = { ... }: { };
}
"#;
let mut fe = FlakeEdit::from_text(content).unwrap();
let v = serde_json::to_value(ListOutput::from(fe.list())).unwrap();
let keys: Vec<&str> = v
.get("inputs")
.unwrap()
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
assert_eq!(keys, vec!["aaa", "zzz"]);
}
#[test]
fn test_is_toplevel_follows() {
for url in [
"harmonia/treefmt-nix",
"clan-core/treefmt-nix",
"clan-core/systems",
] {
assert!(is_toplevel_follows(url), "{url} should be a follows ref");
}
for url in [
"github:NixOS/nixpkgs",
"git+https://git.clan.lol/clan/clan-core",
"path:/some/local/path",
"https://github.com/pinpox.keys",
"/nix/store/abc",
"nixpkgs",
"",
] {
assert!(
!is_toplevel_follows(url),
"{url} should not be a follows ref",
);
}
}
}