1use std::collections::HashMap;
2
3use crate::change::Change;
4use crate::error::Error;
5use crate::input::{Follows, Input};
6use crate::validate;
7use crate::walk::Walker;
8
9pub struct FlakeEdit {
10 walker: Walker,
11}
12
13#[derive(Default, Debug)]
14pub enum Outputs {
15 #[default]
16 None,
17 Multiple(Vec<String>),
18 Any(Vec<String>),
19}
20
21pub type InputMap = HashMap<String, Input>;
22
23pub fn sorted_input_ids(inputs: &InputMap) -> Vec<&String> {
25 let mut keys: Vec<_> = inputs.keys().collect();
26 keys.sort();
27 keys
28}
29
30#[derive(Default, Debug)]
31pub enum OutputChange {
32 #[default]
33 None,
34 Add(String),
35 Remove(String),
36}
37
38#[derive(Debug, Default)]
43pub struct ApplyOutcome {
44 pub text: Option<String>,
45}
46
47impl FlakeEdit {
48 pub fn from_text(stream: &str) -> Result<Self, Error> {
49 let parsed = validate::ParsedSource::new(stream);
50 let validation = validate::validate_parsed(&parsed);
51 if validation.has_errors() {
52 return Err(Error::Validation(validation.errors));
53 }
54
55 let walker = Walker::from_root(parsed.syntax);
56 Ok(Self { walker })
57 }
58
59 #[cfg(feature = "application")]
64 pub(crate) fn from_syntax(syntax: rnix::SyntaxNode) -> Self {
65 Self {
66 walker: Walker::from_root(syntax),
67 }
68 }
69
70 pub fn source_text(&self) -> String {
71 self.walker.root.to_string()
72 }
73
74 pub fn curr_list(&self) -> &InputMap {
75 &self.walker.inputs
76 }
77
78 pub fn list(&mut self) -> &InputMap {
81 self.walker.inputs.clear();
82 assert!(self.walker.walk(&Change::None).ok().flatten().is_none());
84 &self.walker.inputs
85 }
86 pub fn apply_change(&mut self, change: Change) -> Result<ApplyOutcome, Error> {
98 let text = self.apply_change_text(change)?;
99 Ok(ApplyOutcome { text })
100 }
101
102 fn apply_change_text(&mut self, change: Change) -> Result<Option<String>, Error> {
103 match change {
104 Change::None => Ok(None),
105 Change::Add { .. } => self.apply_add(change),
106 Change::Remove { .. } => self.apply_remove(change),
107 Change::Follows { .. } => self.apply_follows(change),
108 Change::Change { .. } => self.apply_change_uri(change),
109 }
110 }
111
112 fn apply_add(&mut self, change: Change) -> Result<Option<String>, Error> {
118 if let Some(input_id) = change.id() {
119 self.ensure_inputs_populated()?;
120
121 let input_id_string = input_id.input().as_str().to_string();
122 if self.walker.inputs.contains_key(&input_id_string) {
123 return Err(Error::DuplicateInput(input_id_string));
124 }
125 }
126
127 if let Some(maybe_changed_node) = self.walker.walk(&change.clone())? {
128 let outputs = self.walker.list_outputs()?;
129 match outputs {
130 Outputs::Multiple(out) => {
131 let id = change.id().unwrap().input().as_str().to_string();
132 if !out.contains(&id) {
133 self.walker.root = maybe_changed_node.clone();
134 if let Some(maybe_changed_node) =
135 self.walker.change_outputs(OutputChange::Add(id))?
136 {
137 return Ok(Some(maybe_changed_node.to_string()));
138 }
139 }
140 }
141 Outputs::None | Outputs::Any(_) => {}
142 }
143 Ok(Some(maybe_changed_node.to_string()))
144 } else {
145 self.walker.add_toplevel = true;
146 let maybe_changed_node = self.walker.walk(&change)?;
147 Ok(maybe_changed_node.map(|n| n.to_string()))
148 }
149 }
150
151 fn apply_remove(&mut self, change: Change) -> Result<Option<String>, Error> {
159 self.ensure_inputs_populated()?;
160
161 let id = change.id().unwrap();
162 let is_toplevel_remove = id.follows().is_none();
163 let removed_id = id.input().as_str().to_string();
164
165 let mut res = None;
166 while let Some(changed_node) = self.walker.walk(&change)? {
167 if res == Some(changed_node.clone()) {
168 break;
169 }
170 res = Some(changed_node.clone());
171 self.walker.root = changed_node.clone();
172 }
173
174 if is_toplevel_remove {
175 let outputs = self.walker.list_outputs()?;
176 match outputs {
177 Outputs::Multiple(out) | Outputs::Any(out) => {
178 if out.contains(&removed_id)
179 && let Some(changed_node) = self
180 .walker
181 .change_outputs(OutputChange::Remove(removed_id.clone()))?
182 {
183 res = Some(changed_node.clone());
184 self.walker.root = changed_node.clone();
185 }
186 }
187 Outputs::None => {}
188 }
189
190 let orphaned_follows = self.collect_orphaned_follows(&removed_id);
191 for orphan_change in orphaned_follows {
192 while let Some(changed_node) = self.walker.walk(&orphan_change)? {
193 if res == Some(changed_node.clone()) {
194 break;
195 }
196 res = Some(changed_node.clone());
197 self.walker.root = changed_node.clone();
198 }
199 }
200 }
201
202 Ok(res.map(|n| n.to_string()))
203 }
204
205 fn apply_follows(&mut self, change: Change) -> Result<Option<String>, Error> {
210 let Change::Follows { ref input, .. } = change else {
211 unreachable!("apply_follows dispatched only for Change::Follows");
212 };
213
214 self.ensure_inputs_populated()?;
215
216 let parent_id = input.input().as_str();
217 if !self.walker.inputs.contains_key(parent_id) {
218 return Err(Error::InputNotFound(parent_id.to_string()));
219 }
220
221 Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
222 }
223
224 fn apply_change_uri(&mut self, change: Change) -> Result<Option<String>, Error> {
231 if let Some(input_id) = change.id() {
232 self.ensure_inputs_populated()?;
233
234 let input_id_string = input_id.input().as_str().to_string();
235 if !self.walker.inputs.contains_key(&input_id_string) {
236 return Err(Error::InputNotFound(input_id_string));
237 }
238 }
239
240 Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
241 }
242
243 pub fn walker(&self) -> &Walker {
244 &self.walker
245 }
246
247 fn ensure_inputs_populated(&mut self) -> Result<(), Error> {
249 if self.walker.inputs.is_empty() {
250 let _ = self.walker.walk(&Change::None)?;
251 }
252 Ok(())
253 }
254
255 fn collect_orphaned_follows(&self, removed_id: &str) -> Vec<Change> {
258 let mut orphaned = Vec::new();
259 for (input_id, input) in &self.walker.inputs {
260 for follows in input.follows() {
261 if let Follows::Indirect {
262 path,
263 target: Some(target),
264 } = follows
265 {
266 if target.first().as_str() == removed_id {
271 let path_str = format!("{}.{}", input_id, path);
272 let Ok(change_id) = crate::change::ChangeId::parse(&path_str) else {
273 continue;
274 };
275 orphaned.push(Change::Remove {
276 ids: vec![change_id],
277 });
278 }
279 }
280 }
281 }
282 orphaned
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn flake_with_nixpkgs_and_crane() -> &'static str {
291 r#"{
292 inputs = {
293 nixpkgs.url = "github:nixos/nixpkgs";
294 crane = {
295 url = "github:ipetkov/crane";
296 };
297 };
298 outputs = { ... }: { };
299}"#
300 }
301
302 #[test]
303 fn none_is_noop() {
304 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
305 let outcome = fe.apply_change(Change::None).unwrap();
306 assert!(outcome.text.is_none(), "Change::None must not produce text");
307 }
308
309 #[test]
310 fn add_inserts_into_existing_inputs_block() {
311 let flake = r#"{
312 inputs = {
313 nixpkgs.url = "github:nixos/nixpkgs";
314 };
315 outputs = { ... }: { };
316}"#;
317 let mut fe = FlakeEdit::from_text(flake).unwrap();
318 let change = Change::Add {
319 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
320 uri: Some("github:ipetkov/crane".into()),
321 flake: true,
322 };
323 let text = fe
324 .apply_change(change)
325 .expect("Add must succeed")
326 .text
327 .expect("Add must produce text");
328 assert!(
329 text.contains("crane.url = \"github:ipetkov/crane\""),
330 "new input must render as a flat url assignment; got:\n{text}",
331 );
332 }
333
334 #[test]
335 fn add_synthesizes_inputs_block_when_absent() {
336 let flake = r#"{
340 outputs = { self, ... }: { };
341}"#;
342 let mut fe = FlakeEdit::from_text(flake).unwrap();
343 let change = Change::Add {
344 id: Some(crate::change::ChangeId::parse("nixpkgs").unwrap()),
345 uri: Some("github:nixos/nixpkgs".into()),
346 flake: true,
347 };
348 let text = fe
349 .apply_change(change)
350 .expect("Add must succeed")
351 .text
352 .expect("Add must produce text");
353 assert!(
354 text.contains("inputs.nixpkgs.url = \"github:nixos/nixpkgs\""),
355 "synthesized toplevel form must use flat url assignment; got:\n{text}",
356 );
357 }
358
359 #[test]
360 fn add_duplicate_returns_duplicate_input_error() {
361 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
362 let change = Change::Add {
363 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
364 uri: Some("github:ipetkov/crane".into()),
365 flake: true,
366 };
367 let err = fe.apply_change(change).expect_err("duplicate must error");
368 assert!(
369 matches!(err, Error::DuplicateInput(ref id) if id == "crane"),
370 "expected DuplicateInput(\"crane\"), got: {err:?}",
371 );
372 }
373
374 #[test]
375 fn remove_strips_existing_input() {
376 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
377 let change = Change::Remove {
378 ids: vec![crate::change::ChangeId::parse("crane").unwrap()],
379 };
380 let text = fe
381 .apply_change(change)
382 .expect("Remove must succeed")
383 .text
384 .expect("Remove must produce text");
385 assert!(
386 !text.contains("crane"),
387 "removed id must not appear; got:\n{text}"
388 );
389 assert!(text.contains("nixpkgs"), "untouched id must remain");
390 }
391
392 #[test]
393 fn remove_scrubs_orphaned_follows_pointing_at_removed_input() {
394 let flake = r#"{
399 inputs = {
400 nixpkgs.url = "github:nixos/nixpkgs";
401 crane = {
402 url = "github:ipetkov/crane";
403 inputs.nixpkgs.follows = "nixpkgs";
404 };
405 };
406 outputs = { ... }: { };
407}"#;
408 let mut fe = FlakeEdit::from_text(flake).unwrap();
409 let change = Change::Remove {
410 ids: vec![crate::change::ChangeId::parse("nixpkgs").unwrap()],
411 };
412 let text = fe
413 .apply_change(change)
414 .expect("Remove must succeed")
415 .text
416 .expect("Remove must produce text");
417 assert!(
418 !text.contains("follows = \"nixpkgs\""),
419 "orphaned follows must be scrubbed; got:\n{text}",
420 );
421 assert!(text.contains("crane"), "sibling input must remain");
422 }
423
424 #[test]
425 fn change_uri_rewrites_existing_input() {
426 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
427 let change = Change::Change {
428 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
429 uri: Some("github:ipetkov/crane/v0.20.0".into()),
430 };
431 let text = fe
432 .apply_change(change)
433 .expect("Change must succeed")
434 .text
435 .expect("Change must produce text");
436 assert!(
437 text.contains("github:ipetkov/crane/v0.20.0"),
438 "new uri must be present; got:\n{text}",
439 );
440 }
441
442 #[test]
443 fn change_uri_missing_input_returns_input_not_found() {
444 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
445 let change = Change::Change {
446 id: Some(crate::change::ChangeId::parse("does-not-exist").unwrap()),
447 uri: Some("github:owner/repo".into()),
448 };
449 let err = fe
450 .apply_change(change)
451 .expect_err("missing input must error");
452 assert!(
453 matches!(err, Error::InputNotFound(ref id) if id == "does-not-exist"),
454 "expected InputNotFound(\"does-not-exist\"), got: {err:?}",
455 );
456 }
457
458 #[test]
459 fn follows_missing_parent_returns_input_not_found() {
460 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
461 let change = Change::Follows {
462 input: crate::change::ChangeId::parse("ghost.nixpkgs").unwrap(),
463 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
464 };
465 let err = fe
466 .apply_change(change)
467 .expect_err("missing parent must error");
468 assert!(
469 matches!(err, Error::InputNotFound(ref id) if id == "ghost"),
470 "expected InputNotFound(\"ghost\"), got: {err:?}",
471 );
472 }
473
474 #[test]
475 fn already_follows_is_noop() {
476 let flake = r#"{
477 inputs = {
478 nixpkgs.url = "github:nixos/nixpkgs";
479 crane = {
480 url = "github:ipetkov/crane";
481 inputs.nixpkgs.follows = "nixpkgs";
482 };
483 };
484 outputs = { ... }: { };
485}"#;
486 let mut fe = FlakeEdit::from_text(flake).unwrap();
487 let original = fe.source_text();
488 let change = Change::Follows {
489 input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
490 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
491 };
492 let result = fe.apply_change(change).unwrap();
493 if let Some(text) = result.text {
496 assert_eq!(text, original, "text should be unchanged");
497 }
498 }
499
500 #[test]
501 fn new_follows_succeeds() {
502 let flake = r#"{
503 inputs = {
504 nixpkgs.url = "github:nixos/nixpkgs";
505 crane = {
506 url = "github:ipetkov/crane";
507 };
508 };
509 outputs = { ... }: { };
510}"#;
511 let mut fe = FlakeEdit::from_text(flake).unwrap();
512 let change = Change::Follows {
513 input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
514 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
515 };
516 let result = fe.apply_change(change);
517 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
518 let text = result.unwrap().text.unwrap();
519 assert!(text.contains("inputs.nixpkgs.follows = \"nixpkgs\""));
520 }
521
522 #[test]
523 fn follows_target_with_dots_renders_as_flat_string() {
524 use crate::follows::{AttrPath, Segment};
525
526 let flake = r#"{
527 inputs = {
528 "ghc-8.6.5-iohk".url = "github:input-output-hk/ghc";
529 crane = {
530 url = "github:ipetkov/crane";
531 };
532 };
533 outputs = { ... }: { };
534}"#;
535 let mut fe = FlakeEdit::from_text(flake).unwrap();
536 let target_seg = Segment::from_unquoted("ghc-8.6.5-iohk").unwrap();
537 let change = Change::Follows {
538 input: crate::change::ChangeId::parse("crane.\"ghc-8.6.5-iohk\"").unwrap(),
539 target: AttrPath::new(target_seg),
540 };
541 let text = fe
542 .apply_change(change)
543 .expect("apply Change::Follows")
544 .text
545 .expect("walker must produce changed text");
546
547 let expected = "inputs.\"ghc-8.6.5-iohk\".follows = \"ghc-8.6.5-iohk\";";
548 assert!(
549 text.contains(expected),
550 "RHS must render as a flat Nix string, got:\n{text}",
551 );
552 assert!(
553 !text.contains(r#""ghc-8."6"."#),
554 "RHS must not contain segment-by-segment quoting, got:\n{text}",
555 );
556 assert!(
557 !text.contains(r#"= ""ghc-8"#),
558 "RHS must not double-quote the target, got:\n{text}",
559 );
560 }
561}