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