1use std::collections::{BTreeMap, HashMap};
2
3use crate::change::Change;
4use crate::error::Error;
5use crate::input::{Follows, Input};
6use crate::validate;
7use crate::walk::{Walker, toggle};
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, Clone, PartialEq, Eq, serde::Serialize)]
41pub struct ToggleState {
42 pub active: String,
44 pub alternates: Vec<String>,
46}
47
48#[derive(Debug, Default)]
53pub struct ApplyOutcome {
54 pub text: Option<String>,
55}
56
57impl FlakeEdit {
58 pub fn from_text(stream: &str) -> Result<Self, Error> {
59 let parsed = validate::ParsedSource::new(stream);
60 let validation = validate::validate_parsed(&parsed);
61 if validation.has_errors() {
62 return Err(Error::Validation(validation.errors));
63 }
64
65 let walker = Walker::from_root(parsed.syntax);
66 Ok(Self { walker })
67 }
68
69 #[cfg(feature = "application")]
74 pub(crate) fn from_syntax(syntax: rnix::SyntaxNode) -> Self {
75 Self {
76 walker: Walker::from_root(syntax),
77 }
78 }
79
80 pub fn source_text(&self) -> String {
81 self.walker.root.to_string()
82 }
83
84 pub fn curr_list(&self) -> &InputMap {
85 &self.walker.inputs
86 }
87
88 pub fn list(&mut self) -> &InputMap {
91 self.walker.inputs.clear();
92 assert!(self.walker.walk(&Change::None).ok().flatten().is_none());
94 &self.walker.inputs
95 }
96 pub fn apply_change(&mut self, change: Change) -> Result<ApplyOutcome, Error> {
108 let text = self.apply_change_text(change)?;
109 Ok(ApplyOutcome { text })
110 }
111
112 fn apply_change_text(&mut self, change: Change) -> Result<Option<String>, Error> {
113 match change {
114 Change::None => Ok(None),
115 Change::Add { .. } => self.apply_add(change),
116 Change::Remove { .. } => self.apply_remove(change),
117 Change::Follows { .. } => self.apply_follows(change),
118 Change::Change { .. } => self.apply_change_uri(change),
119 Change::Toggle { .. } => self.apply_toggle(change),
120 Change::ToggleRemove { .. } => self.apply_toggle_remove(change),
121 }
122 }
123
124 fn apply_add(&mut self, change: Change) -> Result<Option<String>, Error> {
130 if let Some(input_id) = change.id() {
131 self.ensure_inputs_populated()?;
132
133 let input_id_string = input_id.input().as_str().to_string();
134 if self.walker.inputs.contains_key(&input_id_string) {
135 return Err(Error::DuplicateInput(input_id_string));
136 }
137 }
138
139 if let Some(maybe_changed_node) = self.walker.walk(&change.clone())? {
140 let outputs = self.walker.list_outputs()?;
141 match outputs {
142 Outputs::Multiple(out) => {
143 let id = change.id().unwrap().input().as_str().to_string();
144 if !out.contains(&id) {
145 self.walker.root = maybe_changed_node.clone();
146 if let Some(maybe_changed_node) =
147 self.walker.change_outputs(OutputChange::Add(id))?
148 {
149 return Ok(Some(maybe_changed_node.to_string()));
150 }
151 }
152 }
153 Outputs::None | Outputs::Any(_) => {}
154 }
155 Ok(Some(maybe_changed_node.to_string()))
156 } else {
157 self.walker.add_toplevel = true;
158 let maybe_changed_node = self.walker.walk(&change)?;
159 Ok(maybe_changed_node.map(|n| n.to_string()))
160 }
161 }
162
163 fn apply_remove(&mut self, change: Change) -> Result<Option<String>, Error> {
171 self.ensure_inputs_populated()?;
172
173 let id = change.id().unwrap();
174 let is_toplevel_remove = id.follows().is_none();
175 let removed_id = id.input().as_str().to_string();
176
177 let mut res = None;
178 while let Some(changed_node) = self.walker.walk(&change)? {
179 if res == Some(changed_node.clone()) {
180 break;
181 }
182 res = Some(changed_node.clone());
183 self.walker.root = changed_node.clone();
184 }
185
186 if is_toplevel_remove {
187 let outputs = self.walker.list_outputs()?;
188 match outputs {
189 Outputs::Multiple(out) | Outputs::Any(out) => {
190 if out.contains(&removed_id)
191 && let Some(changed_node) = self
192 .walker
193 .change_outputs(OutputChange::Remove(removed_id.clone()))?
194 {
195 res = Some(changed_node.clone());
196 self.walker.root = changed_node.clone();
197 }
198 }
199 Outputs::None => {}
200 }
201
202 let orphaned_follows = self.collect_orphaned_follows(&removed_id);
203 for orphan_change in orphaned_follows {
204 while let Some(changed_node) = self.walker.walk(&orphan_change)? {
205 if res == Some(changed_node.clone()) {
206 break;
207 }
208 res = Some(changed_node.clone());
209 self.walker.root = changed_node.clone();
210 }
211 }
212 }
213
214 Ok(res.map(|n| n.to_string()))
215 }
216
217 fn apply_follows(&mut self, change: Change) -> Result<Option<String>, Error> {
222 let Change::Follows { ref input, .. } = change else {
223 unreachable!("apply_follows dispatched only for Change::Follows");
224 };
225
226 self.ensure_inputs_populated()?;
227
228 let parent_id = input.input().as_str();
229 if !self.walker.inputs.contains_key(parent_id) {
230 return Err(Error::InputNotFound(parent_id.to_string()));
231 }
232
233 Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
234 }
235
236 fn apply_change_uri(&mut self, change: Change) -> Result<Option<String>, Error> {
243 if let Some(input_id) = change.id() {
244 self.ensure_inputs_populated()?;
245
246 let input_id_string = input_id.input().as_str().to_string();
247 if !self.walker.inputs.contains_key(&input_id_string) {
248 return Err(Error::InputNotFound(input_id_string));
249 }
250 }
251
252 Ok(self.walker.walk(&change)?.map(|n| n.to_string()))
253 }
254
255 fn apply_toggle(&mut self, change: Change) -> Result<Option<String>, Error> {
260 let Change::Toggle { id, uri, .. } = change else {
261 unreachable!("apply_toggle dispatched only for Change::Toggle");
262 };
263
264 self.ensure_inputs_populated()?;
265
266 let id_str = id.input().as_str().to_string();
267 let Some(input) = self.walker.inputs.get(&id_str) else {
268 return Err(Error::InputNotFound(id_str));
269 };
270 let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
271 return Err(Error::NoUrlToToggle(id_str));
272 };
273 let parent = binding
274 .parent()
275 .expect("a url binding always sits inside an enclosing node");
276
277 let alternates = toggle::alternates(&binding);
278 if let Some(alternate) = alternates.iter().find(|a| a.url == uri) {
279 return Ok(Some(toggle::flip(&parent, &binding, alternate).to_string()));
280 }
281 if input.url() == uri {
282 return Ok(None);
286 }
287 Ok(Some(
288 toggle::synthesize(&parent, &binding, &uri).to_string(),
289 ))
290 }
291
292 fn apply_toggle_remove(&mut self, change: Change) -> Result<Option<String>, Error> {
298 let Change::ToggleRemove { id, uri, activate } = change else {
299 unreachable!("apply_toggle_remove dispatched only for Change::ToggleRemove");
300 };
301
302 self.ensure_inputs_populated()?;
303
304 let id_str = id.input().as_str().to_string();
305 let Some(input) = self.walker.inputs.get(&id_str) else {
306 return Err(Error::InputNotFound(id_str));
307 };
308 let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
309 return Err(Error::NoUrlToToggle(id_str));
310 };
311 let parent = binding
312 .parent()
313 .expect("a url binding always sits inside an enclosing node");
314
315 let alternates = toggle::alternates(&binding);
316 if let Some(alternate) = alternates.iter().find(|a| a.url == uri) {
317 return Ok(Some(
318 toggle::remove_alternate(&parent, alternate).to_string(),
319 ));
320 }
321 if input.url() == uri {
322 let replacement = activate
323 .and_then(|activate| alternates.into_iter().find(|a| a.url == activate))
324 .ok_or(Error::RemoveActiveWithoutAlternate(id_str))?;
325 return Ok(Some(
326 toggle::flip_remove(&parent, &binding, &replacement).to_string(),
327 ));
328 }
329 Ok(None)
330 }
331
332 pub fn toggle_states(&mut self) -> Result<BTreeMap<String, ToggleState>, Error> {
336 self.ensure_inputs_populated()?;
337 let mut states = BTreeMap::new();
338 for (id, input) in &self.walker.inputs {
339 let Some(binding) = toggle::url_binding(&self.walker.root, input) else {
340 continue;
341 };
342 let alternates = toggle::alternates(&binding)
343 .into_iter()
344 .map(|a| a.url)
345 .collect();
346 states.insert(
347 id.clone(),
348 ToggleState {
349 active: input.url().to_string(),
350 alternates,
351 },
352 );
353 }
354 Ok(states)
355 }
356
357 pub fn walker(&self) -> &Walker {
358 &self.walker
359 }
360
361 fn ensure_inputs_populated(&mut self) -> Result<(), Error> {
363 if self.walker.inputs.is_empty() {
364 let _ = self.walker.walk(&Change::None)?;
365 }
366 Ok(())
367 }
368
369 fn collect_orphaned_follows(&self, removed_id: &str) -> Vec<Change> {
372 let mut orphaned = Vec::new();
373 for (input_id, input) in &self.walker.inputs {
374 for follows in input.follows() {
375 if let Follows::Indirect {
376 path,
377 target: Some(target),
378 } = follows
379 {
380 if target.first().as_str() == removed_id {
385 let path_str = format!("{}.{}", input_id, path);
386 let Ok(change_id) = crate::change::ChangeId::parse(&path_str) else {
387 continue;
388 };
389 orphaned.push(Change::Remove {
390 ids: vec![change_id],
391 });
392 }
393 }
394 }
395 }
396 orphaned
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn flake_with_nixpkgs_and_crane() -> &'static str {
405 r#"{
406 inputs = {
407 nixpkgs.url = "github:nixos/nixpkgs";
408 crane = {
409 url = "github:ipetkov/crane";
410 };
411 };
412 outputs = { ... }: { };
413}"#
414 }
415
416 #[test]
417 fn none_is_noop() {
418 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
419 let outcome = fe.apply_change(Change::None).unwrap();
420 assert!(outcome.text.is_none(), "Change::None must not produce text");
421 }
422
423 #[test]
424 fn add_inserts_into_existing_inputs_block() {
425 let flake = r#"{
426 inputs = {
427 nixpkgs.url = "github:nixos/nixpkgs";
428 };
429 outputs = { ... }: { };
430}"#;
431 let mut fe = FlakeEdit::from_text(flake).unwrap();
432 let change = Change::Add {
433 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
434 uri: Some("github:ipetkov/crane".into()),
435 flake: true,
436 };
437 let text = fe
438 .apply_change(change)
439 .expect("Add must succeed")
440 .text
441 .expect("Add must produce text");
442 assert!(
443 text.contains("crane.url = \"github:ipetkov/crane\""),
444 "new input must render as a flat url assignment; got:\n{text}",
445 );
446 }
447
448 #[test]
449 fn add_synthesizes_inputs_block_when_absent() {
450 let flake = r#"{
454 outputs = { self, ... }: { };
455}"#;
456 let mut fe = FlakeEdit::from_text(flake).unwrap();
457 let change = Change::Add {
458 id: Some(crate::change::ChangeId::parse("nixpkgs").unwrap()),
459 uri: Some("github:nixos/nixpkgs".into()),
460 flake: true,
461 };
462 let text = fe
463 .apply_change(change)
464 .expect("Add must succeed")
465 .text
466 .expect("Add must produce text");
467 assert!(
468 text.contains("inputs.nixpkgs.url = \"github:nixos/nixpkgs\""),
469 "synthesized toplevel form must use flat url assignment; got:\n{text}",
470 );
471 }
472
473 #[test]
474 fn add_duplicate_returns_duplicate_input_error() {
475 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
476 let change = Change::Add {
477 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
478 uri: Some("github:ipetkov/crane".into()),
479 flake: true,
480 };
481 let err = fe.apply_change(change).expect_err("duplicate must error");
482 assert!(
483 matches!(err, Error::DuplicateInput(ref id) if id == "crane"),
484 "expected DuplicateInput(\"crane\"), got: {err:?}",
485 );
486 }
487
488 #[test]
489 fn remove_strips_existing_input() {
490 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
491 let change = Change::Remove {
492 ids: vec![crate::change::ChangeId::parse("crane").unwrap()],
493 };
494 let text = fe
495 .apply_change(change)
496 .expect("Remove must succeed")
497 .text
498 .expect("Remove must produce text");
499 assert!(
500 !text.contains("crane"),
501 "removed id must not appear; got:\n{text}"
502 );
503 assert!(text.contains("nixpkgs"), "untouched id must remain");
504 }
505
506 #[test]
507 fn remove_scrubs_orphaned_follows_pointing_at_removed_input() {
508 let flake = r#"{
513 inputs = {
514 nixpkgs.url = "github:nixos/nixpkgs";
515 crane = {
516 url = "github:ipetkov/crane";
517 inputs.nixpkgs.follows = "nixpkgs";
518 };
519 };
520 outputs = { ... }: { };
521}"#;
522 let mut fe = FlakeEdit::from_text(flake).unwrap();
523 let change = Change::Remove {
524 ids: vec![crate::change::ChangeId::parse("nixpkgs").unwrap()],
525 };
526 let text = fe
527 .apply_change(change)
528 .expect("Remove must succeed")
529 .text
530 .expect("Remove must produce text");
531 assert!(
532 !text.contains("follows = \"nixpkgs\""),
533 "orphaned follows must be scrubbed; got:\n{text}",
534 );
535 assert!(text.contains("crane"), "sibling input must remain");
536 }
537
538 #[test]
539 fn change_uri_rewrites_existing_input() {
540 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
541 let change = Change::Change {
542 id: Some(crate::change::ChangeId::parse("crane").unwrap()),
543 uri: Some("github:ipetkov/crane/v0.20.0".into()),
544 };
545 let text = fe
546 .apply_change(change)
547 .expect("Change must succeed")
548 .text
549 .expect("Change must produce text");
550 assert!(
551 text.contains("github:ipetkov/crane/v0.20.0"),
552 "new uri must be present; got:\n{text}",
553 );
554 }
555
556 #[test]
557 fn change_uri_missing_input_returns_input_not_found() {
558 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
559 let change = Change::Change {
560 id: Some(crate::change::ChangeId::parse("does-not-exist").unwrap()),
561 uri: Some("github:owner/repo".into()),
562 };
563 let err = fe
564 .apply_change(change)
565 .expect_err("missing input must error");
566 assert!(
567 matches!(err, Error::InputNotFound(ref id) if id == "does-not-exist"),
568 "expected InputNotFound(\"does-not-exist\"), got: {err:?}",
569 );
570 }
571
572 #[test]
573 fn follows_missing_parent_returns_input_not_found() {
574 let mut fe = FlakeEdit::from_text(flake_with_nixpkgs_and_crane()).unwrap();
575 let change = Change::Follows {
576 input: crate::change::ChangeId::parse("ghost.nixpkgs").unwrap(),
577 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
578 };
579 let err = fe
580 .apply_change(change)
581 .expect_err("missing parent must error");
582 assert!(
583 matches!(err, Error::InputNotFound(ref id) if id == "ghost"),
584 "expected InputNotFound(\"ghost\"), got: {err:?}",
585 );
586 }
587
588 #[test]
589 fn already_follows_is_noop() {
590 let flake = r#"{
591 inputs = {
592 nixpkgs.url = "github:nixos/nixpkgs";
593 crane = {
594 url = "github:ipetkov/crane";
595 inputs.nixpkgs.follows = "nixpkgs";
596 };
597 };
598 outputs = { ... }: { };
599}"#;
600 let mut fe = FlakeEdit::from_text(flake).unwrap();
601 let original = fe.source_text();
602 let change = Change::Follows {
603 input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
604 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
605 };
606 let result = fe.apply_change(change).unwrap();
607 if let Some(text) = result.text {
610 assert_eq!(text, original, "text should be unchanged");
611 }
612 }
613
614 #[test]
615 fn new_follows_succeeds() {
616 let flake = r#"{
617 inputs = {
618 nixpkgs.url = "github:nixos/nixpkgs";
619 crane = {
620 url = "github:ipetkov/crane";
621 };
622 };
623 outputs = { ... }: { };
624}"#;
625 let mut fe = FlakeEdit::from_text(flake).unwrap();
626 let change = Change::Follows {
627 input: crate::change::ChangeId::parse("crane.nixpkgs").unwrap(),
628 target: crate::follows::AttrPath::parse("nixpkgs").unwrap(),
629 };
630 let result = fe.apply_change(change);
631 assert!(result.is_ok(), "expected Ok, got: {:?}", result);
632 let text = result.unwrap().text.unwrap();
633 assert!(text.contains("inputs.nixpkgs.follows = \"nixpkgs\""));
634 }
635
636 #[test]
637 fn follows_target_with_dots_renders_as_flat_string() {
638 use crate::follows::{AttrPath, Segment};
639
640 let flake = r#"{
641 inputs = {
642 "ghc-8.6.5-iohk".url = "github:input-output-hk/ghc";
643 crane = {
644 url = "github:ipetkov/crane";
645 };
646 };
647 outputs = { ... }: { };
648}"#;
649 let mut fe = FlakeEdit::from_text(flake).unwrap();
650 let target_seg = Segment::from_unquoted("ghc-8.6.5-iohk").unwrap();
651 let change = Change::Follows {
652 input: crate::change::ChangeId::parse("crane.\"ghc-8.6.5-iohk\"").unwrap(),
653 target: AttrPath::new(target_seg),
654 };
655 let text = fe
656 .apply_change(change)
657 .expect("apply Change::Follows")
658 .text
659 .expect("walker must produce changed text");
660
661 let expected = "inputs.\"ghc-8.6.5-iohk\".follows = \"ghc-8.6.5-iohk\";";
662 assert!(
663 text.contains(expected),
664 "RHS must render as a flat Nix string, got:\n{text}",
665 );
666 assert!(
667 !text.contains(r#""ghc-8."6"."#),
668 "RHS must not contain segment-by-segment quoting, got:\n{text}",
669 );
670 assert!(
671 !text.contains(r#"= ""ghc-8"#),
672 "RHS must not double-quote the target, got:\n{text}",
673 );
674 }
675}