1use std::collections::HashSet;
2
3use nix_uri::urls::UrlWrapper;
4use nix_uri::{FlakeRef, NixUriResult};
5use ropey::Rope;
6
7use crate::change::Change;
8use crate::edit::{FlakeEdit, InputMap, sorted_input_ids, sorted_input_ids_owned};
9use crate::error::FlakeEditError;
10use crate::input::Follows;
11use crate::lock::{FlakeLock, NestedInput};
12use crate::tui;
13use crate::update::Updater;
14use crate::validate;
15
16use super::editor::Editor;
17use super::state::AppState;
18
19fn updater(editor: &Editor, inputs: InputMap) -> Updater {
20 Updater::new(Rope::from_str(&editor.text()), inputs)
21}
22
23pub type Result<T> = std::result::Result<T, CommandError>;
24
25#[derive(Debug, thiserror::Error)]
26pub enum CommandError {
27 #[error(transparent)]
28 FlakeEdit(#[from] FlakeEditError),
29
30 #[error(transparent)]
31 Io(#[from] std::io::Error),
32
33 #[error("No URI provided")]
34 NoUri,
35
36 #[error("No ID provided")]
37 NoId,
38
39 #[error("Could not infer ID from flake reference: {0}")]
40 CouldNotInferId(String),
41
42 #[error("Invalid URI: {0}")]
43 InvalidUri(String),
44
45 #[error("No inputs found in the flake")]
46 NoInputs,
47
48 #[error("Could not read flake.lock")]
49 NoLock,
50
51 #[error("Input not found: {0}")]
52 InputNotFound(String),
53
54 #[error("The input could not be removed: {0}")]
55 CouldNotRemove(String),
56}
57
58fn load_flake_lock(state: &AppState) -> std::result::Result<FlakeLock, FlakeEditError> {
60 if let Some(lock_path) = &state.lock_file {
61 FlakeLock::from_file(lock_path)
62 } else {
63 FlakeLock::from_default_path()
64 }
65}
66
67struct FollowContext {
68 nested_inputs: Vec<NestedInput>,
69 top_level_inputs: HashSet<String>,
70 inputs: crate::edit::InputMap,
72}
73
74fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
78 let url_trimmed = url.trim_matches('"');
79 url_trimmed.starts_with(&format!("{}/", parent))
80}
81
82fn load_follow_context(
85 flake_edit: &mut FlakeEdit,
86 state: &AppState,
87) -> Result<Option<FollowContext>> {
88 let nested_inputs: Vec<NestedInput> = load_flake_lock(state)
89 .map(|lock| lock.nested_inputs())
90 .unwrap_or_default();
91
92 if nested_inputs.is_empty() {
93 eprintln!("No nested inputs found in flake.lock");
94 eprintln!("Make sure you have run `nix flake lock` first.");
95 return Ok(None);
96 }
97
98 let inputs = flake_edit.list().clone();
99 let top_level_inputs: HashSet<String> = inputs.keys().cloned().collect();
100
101 if top_level_inputs.is_empty() {
102 return Err(CommandError::NoInputs);
103 }
104
105 Ok(Some(FollowContext {
106 nested_inputs,
107 top_level_inputs,
108 inputs,
109 }))
110}
111
112enum ConfirmResult {
114 Applied,
116 Cancelled,
118 Back,
120}
121
122fn interactive_single_select<F, OnApplied, ExtraData>(
130 editor: &Editor,
131 state: &AppState,
132 title: &str,
133 prompt: &str,
134 items: Vec<String>,
135 make_change: F,
136 on_applied: OnApplied,
137) -> Result<()>
138where
139 F: Fn(&str) -> Result<(String, ExtraData)>,
140 OnApplied: Fn(&str, ExtraData),
141{
142 loop {
143 let select_app = tui::App::select_one(title, prompt, items.clone(), state.diff);
144 let Some(tui::AppResult::SingleSelect(result)) = tui::run(select_app)? else {
145 return Ok(());
146 };
147 let tui::SingleSelectResult {
148 item: id,
149 show_diff,
150 } = result;
151 let (change, extra_data) = make_change(&id)?;
152
153 match confirm_or_apply(editor, state, title, &change, show_diff)? {
154 ConfirmResult::Applied => {
155 on_applied(&id, extra_data);
156 break;
157 }
158 ConfirmResult::Back => continue,
159 ConfirmResult::Cancelled => return Ok(()),
160 }
161 }
162 Ok(())
163}
164
165fn interactive_multi_select<F>(
167 editor: &Editor,
168 state: &AppState,
169 title: &str,
170 prompt: &str,
171 items: Vec<String>,
172 make_change: F,
173) -> Result<()>
174where
175 F: Fn(&[String]) -> String,
176{
177 loop {
178 let select_app = tui::App::select_many(title, prompt, items.clone(), state.diff);
179 let Some(tui::AppResult::MultiSelect(result)) = tui::run(select_app)? else {
180 return Ok(());
181 };
182 let tui::MultiSelectResultData {
183 items: selected,
184 show_diff,
185 } = result;
186 let change = make_change(&selected);
187
188 match confirm_or_apply(editor, state, title, &change, show_diff)? {
189 ConfirmResult::Applied => break,
190 ConfirmResult::Back => continue,
191 ConfirmResult::Cancelled => return Ok(()),
192 }
193 }
194 Ok(())
195}
196
197fn confirm_or_apply(
205 editor: &Editor,
206 state: &AppState,
207 context: &str,
208 change: &str,
209 show_diff: bool,
210) -> Result<ConfirmResult> {
211 if show_diff || state.diff {
212 let diff = crate::diff::Diff::new(&editor.text(), change).to_string_plain();
213 let confirm_app = tui::App::confirm(context, &diff);
214 let Some(tui::AppResult::Confirm(action)) = tui::run(confirm_app)? else {
215 return Ok(ConfirmResult::Cancelled);
216 };
217 match action {
218 tui::ConfirmResultAction::Apply => {
219 let mut apply_state = state.clone();
220 apply_state.diff = false;
221 editor.apply_or_diff(change, &apply_state)?;
222 Ok(ConfirmResult::Applied)
223 }
224 tui::ConfirmResultAction::Back => Ok(ConfirmResult::Back),
225 tui::ConfirmResultAction::Exit => Ok(ConfirmResult::Cancelled),
226 }
227 } else {
228 editor.apply_or_diff(change, state)?;
229 Ok(ConfirmResult::Applied)
230 }
231}
232
233fn apply_uri_options(
235 mut flake_ref: FlakeRef,
236 ref_or_rev: Option<&str>,
237 shallow: bool,
238) -> std::result::Result<FlakeRef, String> {
239 if let Some(ror) = ref_or_rev {
240 flake_ref.r#type.ref_or_rev(Some(ror.to_string())).map_err(|e| {
241 format!(
242 "Cannot apply --ref-or-rev: {}. \
243 The --ref-or-rev option only works with git forge types (github:, gitlab:, sourcehut:) and indirect types (flake:). \
244 For other URI types, use ?ref= or ?rev= query parameters in the URI itself.",
245 e
246 )
247 })?;
248 }
249 if shallow {
250 flake_ref.params.set_shallow(Some("1".to_string()));
251 }
252 Ok(flake_ref)
253}
254
255fn transform_uri(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<String> {
261 let flake_ref: FlakeRef = uri
262 .parse()
263 .map_err(|e| CommandError::InvalidUri(format!("{}: {}", uri, e)))?;
264
265 if ref_or_rev.is_none() && !shallow {
266 return Ok(uri);
267 }
268
269 apply_uri_options(flake_ref, ref_or_rev, shallow)
270 .map(|f| f.to_string())
271 .map_err(CommandError::CouldNotInferId)
272}
273
274#[derive(Default)]
275pub struct UriOptions<'a> {
276 pub ref_or_rev: Option<&'a str>,
277 pub shallow: bool,
278 pub no_flake: bool,
279}
280
281pub fn add(
282 editor: &Editor,
283 flake_edit: &mut FlakeEdit,
284 state: &AppState,
285 id: Option<String>,
286 uri: Option<String>,
287 opts: UriOptions<'_>,
288) -> Result<()> {
289 let change = match (id, uri, state.interactive) {
290 (Some(id_val), Some(uri_str), _) => add_with_id_and_uri(id_val, uri_str, &opts)?,
292 (id, None, true) | (None, id, true) => {
294 add_interactive(editor, state, id.as_deref(), &opts)?
295 }
296 (Some(uri), None, false) | (None, Some(uri), false) => add_infer_id(uri, &opts)?,
298 (None, None, false) => {
300 return Err(CommandError::NoUri);
301 }
302 };
303
304 apply_change(editor, flake_edit, state, change)
305}
306
307fn add_with_id_and_uri(id: String, uri: String, opts: &UriOptions<'_>) -> Result<Change> {
308 let final_uri = transform_uri(uri, opts.ref_or_rev, opts.shallow)?;
309 Ok(Change::Add {
310 id: Some(id),
311 uri: Some(final_uri),
312 flake: !opts.no_flake,
313 })
314}
315
316fn add_interactive(
317 editor: &Editor,
318 state: &AppState,
319 prefill_uri: Option<&str>,
320 opts: &UriOptions<'_>,
321) -> Result<Change> {
322 let tui_app = tui::App::add("Add", editor.text(), prefill_uri, state.cache_config());
323 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
324 return Ok(Change::None);
326 };
327
328 if let Change::Add { id, uri, flake } = tui_change {
330 let final_uri = uri
331 .map(|u| transform_uri(u, opts.ref_or_rev, opts.shallow))
332 .transpose()?;
333 Ok(Change::Add {
334 id,
335 uri: final_uri,
336 flake: flake && !opts.no_flake,
337 })
338 } else {
339 Ok(tui_change)
340 }
341}
342
343fn add_infer_id(uri: String, opts: &UriOptions<'_>) -> Result<Change> {
345 let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
346
347 let (inferred_id, final_uri) = if let Ok(flake_ref) = flake_ref {
348 let flake_ref = apply_uri_options(flake_ref, opts.ref_or_rev, opts.shallow)
349 .map_err(CommandError::CouldNotInferId)?;
350 let parsed_uri = flake_ref.to_string();
351 let final_uri = if parsed_uri.is_empty() || parsed_uri == "none" {
352 uri.clone()
353 } else {
354 parsed_uri
355 };
356 (flake_ref.id(), final_uri)
357 } else {
358 (None, uri.clone())
359 };
360
361 let final_id = inferred_id.ok_or(CommandError::CouldNotInferId(uri))?;
362
363 Ok(Change::Add {
364 id: Some(final_id),
365 uri: Some(final_uri),
366 flake: !opts.no_flake,
367 })
368}
369
370pub fn remove(
371 editor: &Editor,
372 flake_edit: &mut FlakeEdit,
373 state: &AppState,
374 id: Option<String>,
375) -> Result<()> {
376 let change = if let Some(id) = id {
377 Change::Remove {
378 ids: vec![id.into()],
379 }
380 } else if state.interactive {
381 let inputs = flake_edit.list();
382 let mut removable: Vec<String> = Vec::new();
383 for input_id in sorted_input_ids(inputs) {
384 let input = &inputs[input_id];
385 removable.push(input_id.clone());
386 for follows in input.follows() {
387 if let crate::input::Follows::Indirect(from, to) = follows {
388 removable.push(format!("{}.{} => {}", input_id, from, to));
389 }
390 }
391 }
392 if removable.is_empty() {
393 return Err(CommandError::NoInputs);
394 }
395
396 let tui_app = tui::App::remove("Remove", editor.text(), removable);
397 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
398 return Ok(());
399 };
400
401 if let Change::Remove { ids } = tui_change {
403 let stripped_ids: Vec<_> = ids
404 .iter()
405 .map(|id| {
406 id.to_string()
407 .split(" => ")
408 .next()
409 .unwrap_or(&id.to_string())
410 .to_string()
411 .into()
412 })
413 .collect();
414 Change::Remove { ids: stripped_ids }
415 } else {
416 tui_change
417 }
418 } else {
419 return Err(CommandError::NoId);
420 };
421
422 apply_change(editor, flake_edit, state, change)
423}
424
425pub fn change(
426 editor: &Editor,
427 flake_edit: &mut FlakeEdit,
428 state: &AppState,
429 id: Option<String>,
430 uri: Option<String>,
431 ref_or_rev: Option<&str>,
432 shallow: bool,
433) -> Result<()> {
434 let inputs = flake_edit.list();
435
436 let change = match (id, uri, state.interactive) {
437 (None, None, true) | (None, Some(_), true) => {
440 change_full_interactive(editor, state, inputs, ref_or_rev, shallow)?
441 }
442 (Some(id), None, true) => {
444 change_uri_interactive(editor, state, inputs, &id, ref_or_rev, shallow)?
445 }
446 (Some(id_val), Some(uri_str), _) => {
448 change_with_id_and_uri(id_val, uri_str, ref_or_rev, shallow)?
449 }
450 (Some(uri), None, false) | (None, Some(uri), false) => {
452 change_infer_id(uri, ref_or_rev, shallow)?
453 }
454 (None, None, false) => {
456 return Err(CommandError::NoId);
457 }
458 };
459
460 apply_change(editor, flake_edit, state, change)
461}
462
463fn change_full_interactive(
465 editor: &Editor,
466 state: &AppState,
467 inputs: &crate::edit::InputMap,
468 ref_or_rev: Option<&str>,
469 shallow: bool,
470) -> Result<Change> {
471 let input_pairs: Vec<(String, String)> = sorted_input_ids(inputs)
472 .into_iter()
473 .map(|id| (id.clone(), inputs[id].url().trim_matches('"').to_string()))
474 .collect();
475
476 if input_pairs.is_empty() {
477 return Err(CommandError::NoInputs);
478 }
479
480 let tui_app = tui::App::change("Change", editor.text(), input_pairs, state.cache_config());
481 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
482 return Ok(Change::None);
483 };
484
485 if let Change::Change { id, uri, .. } = tui_change {
487 let final_uri = uri
488 .map(|u| transform_uri(u, ref_or_rev, shallow))
489 .transpose()?;
490 Ok(Change::Change {
491 id,
492 uri: final_uri,
493 ref_or_rev: None,
494 })
495 } else {
496 Ok(tui_change)
497 }
498}
499
500fn change_uri_interactive(
502 editor: &Editor,
503 state: &AppState,
504 inputs: &crate::edit::InputMap,
505 id: &str,
506 ref_or_rev: Option<&str>,
507 shallow: bool,
508) -> Result<Change> {
509 let current_uri = inputs.get(id).map(|i| i.url().trim_matches('"'));
510 let tui_app = tui::App::change_uri(
511 "Change",
512 editor.text(),
513 id,
514 current_uri,
515 state.diff,
516 state.cache_config(),
517 );
518
519 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
520 return Ok(Change::None);
521 };
522
523 if let Change::Change {
525 uri: Some(new_uri), ..
526 } = tui_change
527 {
528 let final_uri = transform_uri(new_uri, ref_or_rev, shallow)?;
529 Ok(Change::Change {
530 id: Some(id.to_string()),
531 uri: Some(final_uri),
532 ref_or_rev: None,
533 })
534 } else {
535 Err(CommandError::NoUri)
536 }
537}
538
539fn change_with_id_and_uri(
540 id: String,
541 uri: String,
542 ref_or_rev: Option<&str>,
543 shallow: bool,
544) -> Result<Change> {
545 let final_uri = transform_uri(uri, ref_or_rev, shallow)?;
546 Ok(Change::Change {
547 id: Some(id),
548 uri: Some(final_uri),
549 ref_or_rev: None,
550 })
551}
552
553fn change_infer_id(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<Change> {
555 let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
556
557 let flake_ref = flake_ref.map_err(|_| CommandError::CouldNotInferId(uri.clone()))?;
558 let flake_ref =
559 apply_uri_options(flake_ref, ref_or_rev, shallow).map_err(CommandError::CouldNotInferId)?;
560
561 let final_uri = if flake_ref.to_string().is_empty() {
562 uri.clone()
563 } else {
564 flake_ref.to_string()
565 };
566
567 let id = flake_ref.id().ok_or(CommandError::CouldNotInferId(uri))?;
568
569 Ok(Change::Change {
570 id: Some(id),
571 uri: Some(final_uri),
572 ref_or_rev: None,
573 })
574}
575
576pub fn update(
577 editor: &Editor,
578 flake_edit: &mut FlakeEdit,
579 state: &AppState,
580 id: Option<String>,
581 init: bool,
582) -> Result<()> {
583 let inputs = flake_edit.list().clone();
584 let input_ids = sorted_input_ids_owned(&inputs);
585
586 if let Some(id) = id {
587 let mut updater = updater(editor, inputs);
588 updater.update_all_inputs_to_latest_semver(Some(id), init);
589 let change = updater.get_changes();
590 editor.apply_or_diff(&change, state)?;
591 } else if state.interactive {
592 if input_ids.is_empty() {
593 return Err(CommandError::NoInputs);
594 }
595
596 let display_items: Vec<String> = input_ids
597 .iter()
598 .map(|id| {
599 let input = &inputs[id];
600 let version = input
601 .url()
602 .trim_matches('"')
603 .parse::<FlakeRef>()
604 .ok()
605 .and_then(|f| f.get_ref_or_rev());
606 match version {
607 Some(v) if !v.is_empty() => format!("{} - {}", id, v),
608 _ => id.clone(),
609 }
610 })
611 .collect();
612
613 interactive_multi_select(
614 editor,
615 state,
616 "Update",
617 "Space select, U all, ^D diff",
618 display_items,
619 |selected| {
620 let ids: Vec<String> = selected
622 .iter()
623 .map(|s| s.split(" - ").next().unwrap_or(s).to_string())
624 .collect();
625 let mut updater = updater(editor, inputs.clone());
626 for id in &ids {
627 updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
628 }
629 updater.get_changes()
630 },
631 )?;
632 } else {
633 let mut updater = updater(editor, inputs);
634 for id in &input_ids {
635 updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
636 }
637 let change = updater.get_changes();
638 editor.apply_or_diff(&change, state)?;
639 }
640
641 Ok(())
642}
643
644pub fn pin(
645 editor: &Editor,
646 flake_edit: &mut FlakeEdit,
647 state: &AppState,
648 id: Option<String>,
649 rev: Option<String>,
650) -> Result<()> {
651 let inputs = flake_edit.list().clone();
652 let input_ids = sorted_input_ids_owned(&inputs);
653
654 if let Some(id) = id {
655 let lock = FlakeLock::from_default_path().map_err(|_| CommandError::NoLock)?;
656 let target_rev = if let Some(rev) = rev {
657 rev
658 } else {
659 lock.rev_for(&id)
660 .map_err(|_| CommandError::InputNotFound(id.clone()))?
661 };
662 let mut updater = updater(editor, inputs);
663 updater.pin_input_to_ref(&id, &target_rev);
664 let change = updater.get_changes();
665 editor.apply_or_diff(&change, state)?;
666 if !state.diff {
667 println!("Pinned input: {} to {}", id, target_rev);
668 }
669 } else if state.interactive {
670 if input_ids.is_empty() {
671 return Err(CommandError::NoInputs);
672 }
673 let lock = FlakeLock::from_default_path().map_err(|_| CommandError::NoLock)?;
674
675 interactive_single_select(
676 editor,
677 state,
678 "Pin",
679 "Select input",
680 input_ids,
681 |id| {
682 let target_rev = lock
683 .rev_for(id)
684 .map_err(|_| CommandError::InputNotFound(id.to_string()))?;
685 let mut updater = updater(editor, inputs.clone());
686 updater.pin_input_to_ref(id, &target_rev);
687 Ok((updater.get_changes(), target_rev))
688 },
689 |id, target_rev| println!("Pinned input: {} to {}", id, target_rev),
690 )?;
691 } else {
692 return Err(CommandError::NoId);
693 }
694
695 Ok(())
696}
697
698pub fn unpin(
699 editor: &Editor,
700 flake_edit: &mut FlakeEdit,
701 state: &AppState,
702 id: Option<String>,
703) -> Result<()> {
704 let inputs = flake_edit.list().clone();
705 let input_ids = sorted_input_ids_owned(&inputs);
706
707 if let Some(id) = id {
708 let mut updater = updater(editor, inputs);
709 updater.unpin_input(&id);
710 let change = updater.get_changes();
711 editor.apply_or_diff(&change, state)?;
712 if !state.diff {
713 println!("Unpinned input: {}", id);
714 }
715 } else if state.interactive {
716 if input_ids.is_empty() {
717 return Err(CommandError::NoInputs);
718 }
719
720 interactive_single_select(
721 editor,
722 state,
723 "Unpin",
724 "Select input",
725 input_ids,
726 |id| {
727 let mut updater = updater(editor, inputs.clone());
728 updater.unpin_input(id);
729 Ok((updater.get_changes(), ()))
730 },
731 |id, ()| println!("Unpinned input: {}", id),
732 )?;
733 } else {
734 return Err(CommandError::NoId);
735 }
736
737 Ok(())
738}
739
740pub fn list(flake_edit: &mut FlakeEdit, format: &crate::cli::ListFormat) -> Result<()> {
741 let inputs = flake_edit.list();
742 crate::app::handler::list_inputs(inputs, format);
743 Ok(())
744}
745
746pub fn config(print_default: bool, path: bool) -> Result<()> {
748 use crate::config::{Config, DEFAULT_CONFIG_TOML};
749
750 if print_default {
751 print!("{}", DEFAULT_CONFIG_TOML);
752 return Ok(());
753 }
754
755 if path {
756 let project_path = Config::project_config_path();
758 let user_path = Config::user_config_path();
759
760 if let Some(path) = &project_path {
761 println!("Project config: {}", path.display());
762 }
763 if let Some(path) = &user_path {
764 println!("User config: {}", path.display());
765 }
766
767 if project_path.is_none() && user_path.is_none() {
768 if let Some(user_dir) = Config::user_config_dir() {
769 println!("No config found. Create one at:");
770 println!(" Project: flake-edit.toml (in current directory)");
771 println!(" User: {}/config.toml", user_dir.display());
772 } else {
773 println!("No config found. Create flake-edit.toml in current directory.");
774 }
775 }
776 return Ok(());
777 }
778
779 Ok(())
780}
781
782pub fn follow(
783 editor: &Editor,
784 flake_edit: &mut FlakeEdit,
785 state: &AppState,
786 input: Option<String>,
787 target: Option<String>,
788 auto: bool,
789) -> Result<()> {
790 if auto {
791 return follow_auto(editor, flake_edit, state);
792 }
793
794 let change = if let (Some(input_val), Some(target_val)) = (input.clone(), target) {
795 Change::Follows {
797 input: input_val.into(),
798 target: target_val,
799 }
800 } else if state.interactive {
801 let Some(ctx) = load_follow_context(flake_edit, state)? else {
803 return Ok(());
804 };
805 let top_level_vec: Vec<String> = ctx.top_level_inputs.into_iter().collect();
806
807 let tui_app = if let Some(input_val) = input {
808 tui::App::follow_target("Follow", editor.text(), input_val, top_level_vec)
809 } else {
810 tui::App::follow("Follow", editor.text(), ctx.nested_inputs, top_level_vec)
811 };
812
813 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
814 return Ok(());
815 };
816 tui_change
817 } else {
818 return Err(CommandError::NoId);
819 };
820
821 apply_change(editor, flake_edit, state, change)
822}
823
824fn collect_stale_follows(
827 inputs: &InputMap,
828 existing_nested_paths: &HashSet<String>,
829) -> Vec<String> {
830 let mut stale = Vec::new();
831 for (input_id, input) in inputs {
832 for follows in input.follows() {
833 if let Follows::Indirect(nested_name, _target) = follows {
834 let nested_path = format!("{}.{}", input_id, nested_name);
835 if !existing_nested_paths.contains(&nested_path) {
836 stale.push(nested_path);
837 }
838 }
839 }
840 }
841 stale
842}
843
844fn follow_auto(editor: &Editor, flake_edit: &mut FlakeEdit, state: &AppState) -> Result<()> {
856 let Some(ctx) = load_follow_context(flake_edit, state)? else {
857 return Ok(());
858 };
859
860 let existing_nested_paths: HashSet<String> = load_flake_lock(state)
861 .map(|l| l.nested_input_paths().into_iter().collect())
862 .unwrap_or_default();
863
864 let to_unfollow = collect_stale_follows(&ctx.inputs, &existing_nested_paths);
865
866 let auto_config = &state.config.follow.auto;
867
868 let to_follow: Vec<(String, String)> = ctx
870 .nested_inputs
871 .iter()
872 .filter(|nested| nested.follows.is_none())
873 .filter_map(|nested| {
874 let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
875 let parent = nested.path.split('.').next().unwrap_or(&nested.path);
876
877 if auto_config.is_ignored(&nested.path, nested_name) {
879 tracing::debug!("Skipping {}: ignored by config", nested.path);
880 return None;
881 }
882
883 let matching_top_level = ctx
885 .top_level_inputs
886 .iter()
887 .find(|top| auto_config.can_follow(nested_name, top));
888
889 let target = matching_top_level?;
890
891 if let Some(target_input) = ctx.inputs.get(target.as_str())
895 && is_follows_reference_to_parent(target_input.url(), parent)
896 {
897 tracing::debug!(
898 "Skipping {} -> {}: would create cycle (target follows {}/...)",
899 nested.path,
900 target,
901 parent
902 );
903 return None;
904 }
905
906 Some((nested.path.clone(), target.clone()))
907 })
908 .collect();
909
910 if to_follow.is_empty() && to_unfollow.is_empty() {
911 println!("All inputs are already deduplicated.");
912 return Ok(());
913 }
914
915 let mut current_text = editor.text();
917 let mut applied: Vec<(&str, &str)> = Vec::new();
918
919 for (input_path, target) in &to_follow {
920 let change = Change::Follows {
921 input: input_path.clone().into(),
922 target: target.clone(),
923 };
924
925 let mut temp_flake_edit =
926 FlakeEdit::from_text(¤t_text).map_err(CommandError::FlakeEdit)?;
927
928 match temp_flake_edit.apply_change(change) {
929 Ok(Some(resulting_text)) => {
930 let validation = validate::validate(&resulting_text);
931 if validation.is_ok() {
932 current_text = resulting_text;
933 applied.push((input_path, target));
934 } else {
935 for err in validation.errors {
936 eprintln!("Error applying follows for {}: {}", input_path, err);
937 }
938 }
939 }
940 Ok(None) => eprintln!("Could not create follows for {}", input_path),
941 Err(e) => eprintln!("Error applying follows for {}: {}", input_path, e),
942 }
943 }
944
945 let mut unfollowed: Vec<&str> = Vec::new();
946
947 for nested_path in &to_unfollow {
948 let change = Change::Remove {
949 ids: vec![nested_path.clone().into()],
950 };
951
952 let mut temp_flake_edit =
953 FlakeEdit::from_text(¤t_text).map_err(CommandError::FlakeEdit)?;
954
955 match temp_flake_edit.apply_change(change) {
956 Ok(Some(resulting_text)) => {
957 let validation = validate::validate(&resulting_text);
958 if validation.is_ok() {
959 current_text = resulting_text;
960 unfollowed.push(nested_path);
961 }
962 }
963 Ok(None) => {}
964 Err(e) => eprintln!("Error removing stale follows for {}: {}", nested_path, e),
965 }
966 }
967
968 if applied.is_empty() && unfollowed.is_empty() {
969 return Ok(());
970 }
971
972 if state.diff {
973 let original = editor.text();
974 let diff = crate::diff::Diff::new(&original, ¤t_text);
975 diff.compare();
976 } else {
977 editor.apply_or_diff(¤t_text, state)?;
978
979 if !applied.is_empty() {
980 println!(
981 "Deduplicated {} {}.",
982 applied.len(),
983 if applied.len() == 1 {
984 "input"
985 } else {
986 "inputs"
987 }
988 );
989 for (input_path, target) in &applied {
990 let nested_name = input_path.split('.').next_back().unwrap_or(input_path);
991 let parent = input_path.split('.').next().unwrap_or(input_path);
992 println!(" {}.{} → {}", parent, nested_name, target);
993 }
994 }
995
996 if !unfollowed.is_empty() {
997 println!(
998 "Removed {} stale follows {}.",
999 unfollowed.len(),
1000 if unfollowed.len() == 1 {
1001 "declaration"
1002 } else {
1003 "declarations"
1004 }
1005 );
1006 for path in &unfollowed {
1007 println!(" {} (input no longer exists)", path);
1008 }
1009 }
1010 }
1011
1012 Ok(())
1013}
1014
1015fn apply_change(
1016 editor: &Editor,
1017 flake_edit: &mut FlakeEdit,
1018 state: &AppState,
1019 change: Change,
1020) -> Result<()> {
1021 match flake_edit.apply_change(change.clone()) {
1022 Ok(Some(resulting_change)) => {
1023 let validation = validate::validate(&resulting_change);
1024 if validation.has_errors() {
1025 eprintln!("There are errors in the changes:");
1026 for e in &validation.errors {
1027 tracing::error!("Error: {e}");
1028 }
1029 eprintln!("{}", resulting_change);
1030 eprintln!("There were errors in the changes, the changes have not been applied.");
1031 std::process::exit(1);
1032 }
1033
1034 editor.apply_or_diff(&resulting_change, state)?;
1035
1036 if !state.diff {
1037 if let Change::Add {
1039 id: Some(id),
1040 uri: Some(uri),
1041 ..
1042 } = &change
1043 {
1044 let mut cache = crate::cache::Cache::load();
1045 cache.add_entry(id.clone(), uri.clone());
1046 if let Err(e) = cache.commit() {
1047 tracing::debug!("Could not write to cache: {}", e);
1048 }
1049 }
1050
1051 for msg in change.success_messages() {
1052 println!("{}", msg);
1053 }
1054 }
1055 }
1056 Err(e) => {
1057 return Err(e.into());
1058 }
1059 Ok(None) => {
1060 if change.is_remove() {
1061 return Err(CommandError::CouldNotRemove(
1062 change.id().map(|id| id.to_string()).unwrap_or_default(),
1063 ));
1064 }
1065 if change.is_follows() {
1066 let id = change.id().map(|id| id.to_string()).unwrap_or_default();
1067 eprintln!("The follows relationship for {} could not be created.", id);
1068 eprintln!(
1069 "\nPlease check that the input exists in the flake.nix file.\n\
1070 Use dot notation: `flake-edit follow <input>.<nested-input> <target>`\n\
1071 Example: `flake-edit follow rust-overlay.nixpkgs nixpkgs`"
1072 );
1073 std::process::exit(1);
1074 }
1075 println!("Nothing changed.");
1076 }
1077 }
1078
1079 Ok(())
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn test_is_follows_reference_to_parent() {
1088 assert!(is_follows_reference_to_parent(
1091 "\"clan-core/treefmt-nix\"",
1092 "clan-core"
1093 ));
1094
1095 assert!(is_follows_reference_to_parent(
1097 "clan-core/treefmt-nix",
1098 "clan-core"
1099 ));
1100
1101 assert!(is_follows_reference_to_parent(
1103 "\"some-input/nixpkgs\"",
1104 "some-input"
1105 ));
1106
1107 assert!(!is_follows_reference_to_parent(
1109 "\"github:nixos/nixpkgs\"",
1110 "clan-core"
1111 ));
1112
1113 assert!(!is_follows_reference_to_parent(
1115 "\"github:foo/clan-core-utils\"",
1116 "clan-core"
1117 ));
1118
1119 assert!(!is_follows_reference_to_parent(
1121 "\"clan-core-extended\"",
1122 "clan-core"
1123 ));
1124
1125 assert!(!is_follows_reference_to_parent("", "clan-core"));
1127
1128 assert!(!is_follows_reference_to_parent("\"\"", "clan-core"));
1130 }
1131}