Skip to main content

flake_edit/app/commands/
list.rs

1//! `flake-edit list`: render the input set in one of the supported
2//! formats.
3//!
4//! Owns the [`ListOutput`] / [`InputView`] / [`FollowEdge`] wire
5//! types used by the JSON formatter and the per-format renderers
6//! behind [`ListFormat`].
7
8use std::collections::BTreeMap;
9
10use serde::Serialize;
11
12use crate::cli::ListFormat;
13use crate::edit::{FlakeEdit, InputMap, sorted_input_ids};
14use crate::input::Follows;
15
16use super::Result;
17
18pub fn list(flake_edit: &mut FlakeEdit, format: &ListFormat) -> Result<()> {
19    let inputs = flake_edit.list();
20    list_inputs(inputs, format);
21    Ok(())
22}
23
24/// JSON output for `flake-edit list --format json`.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ListOutput {
27    pub inputs: BTreeMap<String, InputView>,
28    pub follows: Vec<FollowEdge>,
29}
30
31/// One entry in [`ListOutput::inputs`].
32///
33/// `id` and `url` are unquoted (the in-memory invariant). `flake` mirrors the
34/// `inputs.<id>.flake = false;` source-form attribute.
35#[derive(Debug, Clone, PartialEq, Serialize)]
36pub struct InputView {
37    pub id: String,
38    pub url: String,
39    pub flake: bool,
40}
41
42/// One edge in [`ListOutput::follows`].
43///
44/// - `parent` is the top-level input the follows is *declared on*.
45/// - `nested` is the nested input being redirected.
46/// - `target` is the rendered [`crate::follows::AttrPath`] the nested input
47///   is redirected to.
48/// - `kind` distinguishes indirect (URL-less, follows another input) from
49///   direct (URL-bearing) declarations.
50#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct FollowEdge {
52    pub parent: String,
53    pub nested: String,
54    pub target: String,
55    pub kind: FollowEdgeKind,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum FollowEdgeKind {
61    Indirect,
62    Direct,
63}
64
65impl From<&InputMap> for ListOutput {
66    fn from(inputs: &InputMap) -> Self {
67        let mut input_views: BTreeMap<String, InputView> = BTreeMap::new();
68        let mut follows: Vec<FollowEdge> = Vec::new();
69        for key in sorted_input_ids(inputs) {
70            let input = &inputs[key];
71            let parent_id = input.id().as_str().to_string();
72            input_views.insert(
73                key.clone(),
74                InputView {
75                    id: parent_id.clone(),
76                    url: input.url().to_string(),
77                    flake: input.flake,
78                },
79            );
80            for f in input.follows() {
81                match f {
82                    Follows::Indirect { path, target } => {
83                        follows.push(FollowEdge {
84                            parent: parent_id.clone(),
85                            nested: path.to_string(),
86                            target: target
87                                .as_ref()
88                                .map(|t| t.to_flake_follows_string())
89                                .unwrap_or_default(),
90                            kind: FollowEdgeKind::Indirect,
91                        });
92                    }
93                    Follows::Direct(name, child) => {
94                        follows.push(FollowEdge {
95                            parent: parent_id.clone(),
96                            nested: name.clone(),
97                            target: child.url().to_string(),
98                            kind: FollowEdgeKind::Direct,
99                        });
100                    }
101                }
102            }
103        }
104        ListOutput {
105            inputs: input_views,
106            follows,
107        }
108    }
109}
110
111/// Dispatches to the renderer matching `format` and prints the
112/// result on stdout.
113pub(super) fn list_inputs(inputs: &InputMap, format: &ListFormat) {
114    match format {
115        ListFormat::Simple => list_simple(inputs),
116        ListFormat::Json => list_json(inputs),
117        ListFormat::Detailed => list_detailed(inputs),
118        ListFormat::Toplevel => list_toplevel(inputs),
119    }
120}
121
122fn list_simple(inputs: &InputMap) {
123    let mut buf = String::new();
124    for key in sorted_input_ids(inputs) {
125        let input = &inputs[key];
126        if !buf.is_empty() {
127            buf.push('\n');
128        }
129        buf.push_str(input.id().as_str());
130        for follows in input.follows() {
131            if let Follows::Indirect { path, .. } = follows {
132                let id = format!("{}.{}", input.id().as_str(), path);
133                if !buf.is_empty() {
134                    buf.push('\n');
135                }
136                buf.push_str(&id);
137            }
138        }
139    }
140    println!("{buf}");
141}
142
143fn list_json(inputs: &InputMap) {
144    let out: ListOutput = inputs.into();
145    println!("{}", serde_json::to_string(&out).unwrap());
146}
147
148fn list_toplevel(inputs: &InputMap) {
149    let mut buf = String::new();
150    for key in sorted_input_ids(inputs) {
151        if !buf.is_empty() {
152            buf.push('\n');
153        }
154        buf.push_str(&key.to_string());
155    }
156    println!("{buf}");
157}
158
159/// Returns `true` when `url` is a top-level follows reference (for
160/// example `harmonia/treefmt-nix`) rather than a real URL with a
161/// `github:` or `git+` protocol prefix.
162fn is_toplevel_follows(url: &str) -> bool {
163    !url.is_empty() && !url.contains(':') && url.contains('/') && !url.starts_with('/')
164}
165
166fn list_detailed(inputs: &InputMap) {
167    let mut buf = String::new();
168    for key in sorted_input_ids(inputs) {
169        let input = &inputs[key];
170        if !buf.is_empty() {
171            buf.push('\n');
172        }
173        let line = if is_toplevel_follows(input.url()) {
174            format!("· {} <= {}", input.id().as_str(), input.url())
175        } else {
176            format!("· {} - {}", input.id().as_str(), input.url())
177        };
178        buf.push_str(&line);
179        for follows in input.follows() {
180            if let Follows::Indirect { path, target } = follows {
181                // Render an empty `follows = ""` as `=> ""` to mirror the
182                // source-flake form. Non-empty targets render bare.
183                let target_str = match target {
184                    Some(t) => t.to_flake_follows_string(),
185                    None => "\"\"".to_string(),
186                };
187                let id = format!("{}{} => {}", " ".repeat(5), path, target_str);
188                if !buf.is_empty() {
189                    buf.push('\n');
190                }
191                buf.push_str(&id);
192            }
193        }
194    }
195    println!("{buf}");
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::edit::FlakeEdit;
202    use crate::follows::{AttrPath, Segment};
203    use crate::input::{Follows, Input, Range};
204    use serde_json::json;
205
206    #[test]
207    fn list_output_empty_inputs_is_empty_shape() {
208        let inputs: InputMap = InputMap::new();
209        let out: ListOutput = (&inputs).into();
210        let v = serde_json::to_value(&out).unwrap();
211        assert_eq!(v, json!({ "inputs": {}, "follows": [] }));
212    }
213
214    #[test]
215    fn list_output_single_toplevel_no_follows() {
216        let mut inputs = InputMap::new();
217        let id = Segment::from_unquoted("nixpkgs").unwrap();
218        let mut input = Input::new(id);
219        input.url = "github:nixos/nixpkgs/nixos-unstable".into();
220        inputs.insert("nixpkgs".into(), input);
221        let v = serde_json::to_value(ListOutput::from(&inputs)).unwrap();
222        assert_eq!(
223            v,
224            json!({
225                "inputs": {
226                    "nixpkgs": {
227                        "id": "nixpkgs",
228                        "url": "github:nixos/nixpkgs/nixos-unstable",
229                        "flake": true,
230                    }
231                },
232                "follows": [],
233            })
234        );
235    }
236
237    #[test]
238    fn list_output_renders_indirect_follows_as_flat_array() {
239        let mut inputs = InputMap::new();
240        let crane = Segment::from_unquoted("crane").unwrap();
241        let mut input = Input::new(crane);
242        input.url = "github:ipetkov/crane".into();
243        input.range = Range {
244            start: 100,
245            end: 120,
246        };
247        input.follows.push(Follows::Indirect {
248            path: AttrPath::new(Segment::from_unquoted("nixpkgs").unwrap()),
249            target: Some(AttrPath::parse("nixpkgs").unwrap()),
250        });
251        inputs.insert("crane".into(), input);
252        let v = serde_json::to_value(ListOutput::from(&inputs)).unwrap();
253        assert_eq!(
254            v,
255            json!({
256                "inputs": {
257                    "crane": {
258                        "id": "crane",
259                        "url": "github:ipetkov/crane",
260                        "flake": true,
261                    }
262                },
263                "follows": [
264                    {
265                        "parent": "crane",
266                        "nested": "nixpkgs",
267                        "target": "nixpkgs",
268                        "kind": "indirect"
269                    }
270                ],
271            })
272        );
273    }
274
275    #[test]
276    fn list_output_url_is_unquoted() {
277        // URLs are stored unquoted in memory. The ListOutput JSON wire form
278        // surfaces them unquoted too.
279        let mut inputs = InputMap::new();
280        let id = Segment::from_unquoted("nixpkgs").unwrap();
281        let mut input = Input::new(id);
282        input.url = "github:nixos/nixpkgs".into();
283        inputs.insert("nixpkgs".into(), input);
284        let s = serde_json::to_string(&ListOutput::from(&inputs)).unwrap();
285        assert!(
286            !s.contains("\\\"github:"),
287            "URL was double-quoted in JSON output: {s}",
288        );
289        assert!(
290            s.contains("\"url\":\"github:nixos/nixpkgs\""),
291            "expected unquoted url field in JSON output: {s}",
292        );
293    }
294
295    #[test]
296    fn list_output_kind_serialises_kebab_case() {
297        let edge = FollowEdge {
298            parent: "a".into(),
299            nested: "b".into(),
300            target: "c".into(),
301            kind: FollowEdgeKind::Indirect,
302        };
303        let v = serde_json::to_value(&edge).unwrap();
304        assert_eq!(v.get("kind").unwrap(), &json!("indirect"));
305    }
306
307    #[test]
308    fn list_output_inputs_sorted_by_id() {
309        let content = r#"{
310            inputs.zzz.url = "github:ex/zzz";
311            inputs.aaa.url = "github:ex/aaa";
312            outputs = { ... }: { };
313        }
314        "#;
315        let mut fe = FlakeEdit::from_text(content).unwrap();
316        let v = serde_json::to_value(ListOutput::from(fe.list())).unwrap();
317        let keys: Vec<&str> = v
318            .get("inputs")
319            .unwrap()
320            .as_object()
321            .unwrap()
322            .keys()
323            .map(|s| s.as_str())
324            .collect();
325        assert_eq!(keys, vec!["aaa", "zzz"]);
326    }
327
328    #[test]
329    fn test_is_toplevel_follows() {
330        for url in [
331            "harmonia/treefmt-nix",
332            "clan-core/treefmt-nix",
333            "clan-core/systems",
334        ] {
335            assert!(is_toplevel_follows(url), "{url} should be a follows ref");
336        }
337        for url in [
338            "github:NixOS/nixpkgs",
339            "git+https://git.clan.lol/clan/clan-core",
340            "path:/some/local/path",
341            "https://github.com/pinpox.keys",
342            "/nix/store/abc",
343            "nixpkgs",
344            "",
345        ] {
346            assert!(
347                !is_toplevel_follows(url),
348                "{url} should not be a follows ref",
349            );
350        }
351    }
352}