1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ListOutput {
27 pub inputs: BTreeMap<String, InputView>,
28 pub follows: Vec<FollowEdge>,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize)]
36pub struct InputView {
37 pub id: String,
38 pub url: String,
39 pub flake: bool,
40}
41
42#[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
111pub(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
159fn 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 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 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}