1use std::collections::{HashMap, HashSet};
2
3use nix_uri::urls::UrlWrapper;
4use nix_uri::{FlakeRef, NixUriResult};
5use ropey::Rope;
6
7use crate::change::{Change, split_quoted_path};
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("Input '{0}' has no pinnable URL (it may use follows or a non-standard format)")]
61 InputNotPinnable(String),
62
63 #[error("The input could not be removed: {0}")]
64 CouldNotRemove(String),
65}
66
67fn load_flake_lock(state: &AppState) -> std::result::Result<FlakeLock, FlakeEditError> {
69 if let Some(lock_path) = &state.lock_file {
70 FlakeLock::from_file(lock_path)
71 } else {
72 FlakeLock::from_default_path()
73 }
74}
75
76struct FollowContext {
77 nested_inputs: Vec<NestedInput>,
78 top_level_inputs: HashSet<String>,
79 inputs: crate::edit::InputMap,
81}
82
83fn is_follows_reference_to_parent(url: &str, parent: &str) -> bool {
87 let url_trimmed = url.trim_matches('"');
88 url_trimmed.starts_with(&format!("{}/", parent))
89}
90
91fn collect_existing_follows(inputs: &InputMap) -> HashSet<String> {
96 let mut existing = HashSet::new();
97 for (input_id, input) in inputs {
98 for follows in input.follows() {
99 if let Follows::Indirect(nested_name, _target) = follows {
100 let raw = format!("{}.{}", input_id, nested_name);
101 existing.insert(normalize_nested_path(&raw));
102 }
103 }
104 }
105 existing
106}
107
108fn lock_follows_to_flake_target(target: &str) -> String {
110 if target.contains('.') {
111 target.replace('.', "/")
112 } else {
113 target.to_string()
114 }
115}
116
117fn load_follow_context(
119 flake_edit: &mut FlakeEdit,
120 state: &AppState,
121) -> Result<Option<FollowContext>> {
122 let nested_inputs: Vec<NestedInput> = match load_flake_lock(state) {
123 Ok(lock) => lock.nested_inputs(),
124 Err(e) => {
125 let lock_path = state
126 .lock_file
127 .as_ref()
128 .map(|p| p.display().to_string())
129 .unwrap_or_else(|| "flake.lock".to_string());
130 return Err(CommandError::LockFileError {
131 path: lock_path,
132 source: e,
133 });
134 }
135 };
136
137 if nested_inputs.is_empty() {
138 return Ok(None);
139 }
140
141 let inputs = flake_edit.list().clone();
142 let top_level_inputs: HashSet<String> = inputs.keys().cloned().collect();
143
144 if top_level_inputs.is_empty() {
145 return Err(CommandError::NoInputs);
146 }
147
148 Ok(Some(FollowContext {
149 nested_inputs,
150 top_level_inputs,
151 inputs,
152 }))
153}
154
155enum ConfirmResult {
157 Applied,
159 Cancelled,
161 Back,
163}
164
165fn interactive_single_select<F, OnApplied, ExtraData>(
173 editor: &Editor,
174 state: &AppState,
175 title: &str,
176 prompt: &str,
177 items: Vec<String>,
178 make_change: F,
179 on_applied: OnApplied,
180) -> Result<()>
181where
182 F: Fn(&str) -> Result<(String, ExtraData)>,
183 OnApplied: Fn(&str, ExtraData),
184{
185 loop {
186 let select_app = tui::App::select_one(title, prompt, items.clone(), state.diff);
187 let Some(tui::AppResult::SingleSelect(result)) = tui::run(select_app)? else {
188 return Ok(());
189 };
190 let tui::SingleSelectResult {
191 item: id,
192 show_diff,
193 } = result;
194 let (change, extra_data) = make_change(&id)?;
195
196 match confirm_or_apply(editor, state, title, &change, show_diff)? {
197 ConfirmResult::Applied => {
198 on_applied(&id, extra_data);
199 break;
200 }
201 ConfirmResult::Back => continue,
202 ConfirmResult::Cancelled => return Ok(()),
203 }
204 }
205 Ok(())
206}
207
208fn interactive_multi_select<F>(
210 editor: &Editor,
211 state: &AppState,
212 title: &str,
213 prompt: &str,
214 items: Vec<String>,
215 make_change: F,
216) -> Result<()>
217where
218 F: Fn(&[String]) -> String,
219{
220 loop {
221 let select_app = tui::App::select_many(title, prompt, items.clone(), state.diff);
222 let Some(tui::AppResult::MultiSelect(result)) = tui::run(select_app)? else {
223 return Ok(());
224 };
225 let tui::MultiSelectResultData {
226 items: selected,
227 show_diff,
228 } = result;
229 let change = make_change(&selected);
230
231 match confirm_or_apply(editor, state, title, &change, show_diff)? {
232 ConfirmResult::Applied => break,
233 ConfirmResult::Back => continue,
234 ConfirmResult::Cancelled => return Ok(()),
235 }
236 }
237 Ok(())
238}
239
240fn confirm_or_apply(
248 editor: &Editor,
249 state: &AppState,
250 context: &str,
251 change: &str,
252 show_diff: bool,
253) -> Result<ConfirmResult> {
254 if show_diff || state.diff {
255 let diff = crate::diff::Diff::new(&editor.text(), change).to_string_plain();
256 let confirm_app = tui::App::confirm(context, &diff);
257 let Some(tui::AppResult::Confirm(action)) = tui::run(confirm_app)? else {
258 return Ok(ConfirmResult::Cancelled);
259 };
260 match action {
261 tui::ConfirmResultAction::Apply => {
262 let mut apply_state = state.clone();
263 apply_state.diff = false;
264 editor.apply_or_diff(change, &apply_state)?;
265 Ok(ConfirmResult::Applied)
266 }
267 tui::ConfirmResultAction::Back => Ok(ConfirmResult::Back),
268 tui::ConfirmResultAction::Exit => Ok(ConfirmResult::Cancelled),
269 }
270 } else {
271 editor.apply_or_diff(change, state)?;
272 Ok(ConfirmResult::Applied)
273 }
274}
275
276fn apply_uri_options(
278 mut flake_ref: FlakeRef,
279 ref_or_rev: Option<&str>,
280 shallow: bool,
281) -> std::result::Result<FlakeRef, String> {
282 if let Some(ror) = ref_or_rev {
283 flake_ref.r#type.ref_or_rev(Some(ror.to_string())).map_err(|e| {
284 format!(
285 "Cannot apply --ref-or-rev: {}. \
286 The --ref-or-rev option only works with git forge types (github:, gitlab:, sourcehut:) and indirect types (flake:). \
287 For other URI types, use ?ref= or ?rev= query parameters in the URI itself.",
288 e
289 )
290 })?;
291 }
292 if shallow {
293 flake_ref.params.set_shallow(Some("1".to_string()));
294 }
295 Ok(flake_ref)
296}
297
298fn transform_uri(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<String> {
304 let flake_ref: FlakeRef = uri
305 .parse()
306 .map_err(|e| CommandError::InvalidUri(format!("{}: {}", uri, e)))?;
307
308 if ref_or_rev.is_none() && !shallow {
309 return Ok(uri);
310 }
311
312 apply_uri_options(flake_ref, ref_or_rev, shallow)
313 .map(|f| f.to_string())
314 .map_err(CommandError::CouldNotInferId)
315}
316
317#[derive(Default)]
318pub struct UriOptions<'a> {
319 pub ref_or_rev: Option<&'a str>,
320 pub shallow: bool,
321 pub no_flake: bool,
322}
323
324pub fn add(
325 editor: &Editor,
326 flake_edit: &mut FlakeEdit,
327 state: &AppState,
328 id: Option<String>,
329 uri: Option<String>,
330 opts: UriOptions<'_>,
331) -> Result<()> {
332 let change = match (id, uri, state.interactive) {
333 (Some(id_val), Some(uri_str), _) => add_with_id_and_uri(id_val, uri_str, &opts)?,
335 (id, None, true) | (None, id, true) => {
337 add_interactive(editor, state, id.as_deref(), &opts)?
338 }
339 (Some(uri), None, false) | (None, Some(uri), false) => add_infer_id(uri, &opts)?,
341 (None, None, false) => {
343 return Err(CommandError::NoUri);
344 }
345 };
346
347 apply_change(editor, flake_edit, state, change)
348}
349
350fn add_with_id_and_uri(id: String, uri: String, opts: &UriOptions<'_>) -> Result<Change> {
351 let final_uri = transform_uri(uri, opts.ref_or_rev, opts.shallow)?;
352 Ok(Change::Add {
353 id: Some(id),
354 uri: Some(final_uri),
355 flake: !opts.no_flake,
356 })
357}
358
359fn add_interactive(
360 editor: &Editor,
361 state: &AppState,
362 prefill_uri: Option<&str>,
363 opts: &UriOptions<'_>,
364) -> Result<Change> {
365 let tui_app = tui::App::add("Add", editor.text(), prefill_uri, state.cache_config());
366 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
367 return Ok(Change::None);
369 };
370
371 if let Change::Add { id, uri, flake } = tui_change {
373 let final_uri = uri
374 .map(|u| transform_uri(u, opts.ref_or_rev, opts.shallow))
375 .transpose()?;
376 Ok(Change::Add {
377 id,
378 uri: final_uri,
379 flake: flake && !opts.no_flake,
380 })
381 } else {
382 Ok(tui_change)
383 }
384}
385
386fn add_infer_id(uri: String, opts: &UriOptions<'_>) -> Result<Change> {
388 let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
389
390 let (inferred_id, final_uri) = if let Ok(flake_ref) = flake_ref {
391 let flake_ref = apply_uri_options(flake_ref, opts.ref_or_rev, opts.shallow)
392 .map_err(CommandError::CouldNotInferId)?;
393 let parsed_uri = flake_ref.to_string();
394 let final_uri = if parsed_uri.is_empty() || parsed_uri == "none" {
395 uri.clone()
396 } else {
397 parsed_uri
398 };
399 (flake_ref.id(), final_uri)
400 } else {
401 (None, uri.clone())
402 };
403
404 let final_id = inferred_id.ok_or(CommandError::CouldNotInferId(uri))?;
405
406 Ok(Change::Add {
407 id: Some(final_id),
408 uri: Some(final_uri),
409 flake: !opts.no_flake,
410 })
411}
412
413pub fn remove(
414 editor: &Editor,
415 flake_edit: &mut FlakeEdit,
416 state: &AppState,
417 id: Option<String>,
418) -> Result<()> {
419 let change = if let Some(id) = id {
420 Change::Remove {
421 ids: vec![id.into()],
422 }
423 } else if state.interactive {
424 let inputs = flake_edit.list();
425 let mut removable: Vec<String> = Vec::new();
426 for input_id in sorted_input_ids(inputs) {
427 let input = &inputs[input_id];
428 removable.push(input_id.clone());
429 for follows in input.follows() {
430 if let crate::input::Follows::Indirect(from, to) = follows {
431 removable.push(format!("{}.{} => {}", input_id, from, to));
432 }
433 }
434 }
435 if removable.is_empty() {
436 return Err(CommandError::NoInputs);
437 }
438
439 let tui_app = tui::App::remove("Remove", editor.text(), removable);
440 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
441 return Ok(());
442 };
443
444 if let Change::Remove { ids } = tui_change {
446 let stripped_ids: Vec<_> = ids
447 .iter()
448 .map(|id| {
449 id.to_string()
450 .split(" => ")
451 .next()
452 .unwrap_or(&id.to_string())
453 .to_string()
454 .into()
455 })
456 .collect();
457 Change::Remove { ids: stripped_ids }
458 } else {
459 tui_change
460 }
461 } else {
462 return Err(CommandError::NoId);
463 };
464
465 apply_change(editor, flake_edit, state, change)
466}
467
468pub fn change(
469 editor: &Editor,
470 flake_edit: &mut FlakeEdit,
471 state: &AppState,
472 id: Option<String>,
473 uri: Option<String>,
474 ref_or_rev: Option<&str>,
475 shallow: bool,
476) -> Result<()> {
477 let inputs = flake_edit.list();
478
479 let change = match (id, uri, state.interactive) {
480 (None, None, true) | (None, Some(_), true) => {
483 change_full_interactive(editor, state, inputs, ref_or_rev, shallow)?
484 }
485 (Some(id), None, true) => {
487 change_uri_interactive(editor, state, inputs, &id, ref_or_rev, shallow)?
488 }
489 (Some(id_val), Some(uri_str), _) => {
491 change_with_id_and_uri(id_val, uri_str, ref_or_rev, shallow)?
492 }
493 (Some(uri), None, false) | (None, Some(uri), false) => {
495 change_infer_id(uri, ref_or_rev, shallow)?
496 }
497 (None, None, false) => {
499 return Err(CommandError::NoId);
500 }
501 };
502
503 apply_change(editor, flake_edit, state, change)
504}
505
506fn change_full_interactive(
508 editor: &Editor,
509 state: &AppState,
510 inputs: &crate::edit::InputMap,
511 ref_or_rev: Option<&str>,
512 shallow: bool,
513) -> Result<Change> {
514 let input_pairs: Vec<(String, String)> = sorted_input_ids(inputs)
515 .into_iter()
516 .map(|id| (id.clone(), inputs[id].url().trim_matches('"').to_string()))
517 .collect();
518
519 if input_pairs.is_empty() {
520 return Err(CommandError::NoInputs);
521 }
522
523 let tui_app = tui::App::change("Change", editor.text(), input_pairs, state.cache_config());
524 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
525 return Ok(Change::None);
526 };
527
528 if let Change::Change { id, uri, .. } = tui_change {
530 let final_uri = uri
531 .map(|u| transform_uri(u, ref_or_rev, shallow))
532 .transpose()?;
533 Ok(Change::Change {
534 id,
535 uri: final_uri,
536 ref_or_rev: None,
537 })
538 } else {
539 Ok(tui_change)
540 }
541}
542
543fn change_uri_interactive(
545 editor: &Editor,
546 state: &AppState,
547 inputs: &crate::edit::InputMap,
548 id: &str,
549 ref_or_rev: Option<&str>,
550 shallow: bool,
551) -> Result<Change> {
552 let current_uri = inputs.get(id).map(|i| i.url().trim_matches('"'));
553 let tui_app = tui::App::change_uri(
554 "Change",
555 editor.text(),
556 id,
557 current_uri,
558 state.diff,
559 state.cache_config(),
560 );
561
562 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
563 return Ok(Change::None);
564 };
565
566 if let Change::Change {
568 uri: Some(new_uri), ..
569 } = tui_change
570 {
571 let final_uri = transform_uri(new_uri, ref_or_rev, shallow)?;
572 Ok(Change::Change {
573 id: Some(id.to_string()),
574 uri: Some(final_uri),
575 ref_or_rev: None,
576 })
577 } else {
578 Err(CommandError::NoUri)
579 }
580}
581
582fn change_with_id_and_uri(
583 id: String,
584 uri: String,
585 ref_or_rev: Option<&str>,
586 shallow: bool,
587) -> Result<Change> {
588 let final_uri = transform_uri(uri, ref_or_rev, shallow)?;
589 Ok(Change::Change {
590 id: Some(id),
591 uri: Some(final_uri),
592 ref_or_rev: None,
593 })
594}
595
596fn change_infer_id(uri: String, ref_or_rev: Option<&str>, shallow: bool) -> Result<Change> {
598 let flake_ref: NixUriResult<FlakeRef> = UrlWrapper::convert_or_parse(&uri);
599
600 let flake_ref = flake_ref.map_err(|_| CommandError::CouldNotInferId(uri.clone()))?;
601 let flake_ref =
602 apply_uri_options(flake_ref, ref_or_rev, shallow).map_err(CommandError::CouldNotInferId)?;
603
604 let final_uri = if flake_ref.to_string().is_empty() {
605 uri.clone()
606 } else {
607 flake_ref.to_string()
608 };
609
610 let id = flake_ref.id().ok_or(CommandError::CouldNotInferId(uri))?;
611
612 Ok(Change::Change {
613 id: Some(id),
614 uri: Some(final_uri),
615 ref_or_rev: None,
616 })
617}
618
619pub fn update(
620 editor: &Editor,
621 flake_edit: &mut FlakeEdit,
622 state: &AppState,
623 id: Option<String>,
624 init: bool,
625) -> Result<()> {
626 let inputs = flake_edit.list().clone();
627 let input_ids = sorted_input_ids_owned(&inputs);
628
629 if let Some(id) = id {
630 let mut updater = updater(editor, inputs);
631 updater.update_all_inputs_to_latest_semver(Some(id), init);
632 let change = updater.get_changes();
633 editor.apply_or_diff(&change, state)?;
634 } else if state.interactive {
635 if input_ids.is_empty() {
636 return Err(CommandError::NoInputs);
637 }
638
639 let display_items: Vec<String> = input_ids
640 .iter()
641 .map(|id| {
642 let input = &inputs[id];
643 let version = input
644 .url()
645 .trim_matches('"')
646 .parse::<FlakeRef>()
647 .ok()
648 .and_then(|f| f.get_ref_or_rev());
649 match version {
650 Some(v) if !v.is_empty() => format!("{} - {}", id, v),
651 _ => id.clone(),
652 }
653 })
654 .collect();
655
656 interactive_multi_select(
657 editor,
658 state,
659 "Update",
660 "Space select, U all, ^D diff",
661 display_items,
662 |selected| {
663 let ids: Vec<String> = selected
665 .iter()
666 .map(|s| s.split(" - ").next().unwrap_or(s).to_string())
667 .collect();
668 let mut updater = updater(editor, inputs.clone());
669 for id in &ids {
670 updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
671 }
672 updater.get_changes()
673 },
674 )?;
675 } else {
676 let mut updater = updater(editor, inputs);
677 for id in &input_ids {
678 updater.update_all_inputs_to_latest_semver(Some(id.clone()), init);
679 }
680 let change = updater.get_changes();
681 editor.apply_or_diff(&change, state)?;
682 }
683
684 Ok(())
685}
686
687pub fn pin(
688 editor: &Editor,
689 flake_edit: &mut FlakeEdit,
690 state: &AppState,
691 id: Option<String>,
692 rev: Option<String>,
693) -> Result<()> {
694 let inputs = flake_edit.list().clone();
695 let input_ids = sorted_input_ids_owned(&inputs);
696
697 if let Some(id) = id {
698 let lock = load_flake_lock(state).map_err(|e| CommandError::LockFileError {
699 path: state
700 .lock_file
701 .as_ref()
702 .map(|p| p.display().to_string())
703 .unwrap_or_else(|| "flake.lock".to_string()),
704 source: e,
705 })?;
706 let target_rev = if let Some(rev) = rev {
707 rev
708 } else {
709 lock.rev_for(&id)
710 .map_err(|_| CommandError::InputNotFound(id.clone()))?
711 };
712 let mut updater = updater(editor, inputs);
713 updater
714 .pin_input_to_ref(&id, &target_rev)
715 .map_err(CommandError::InputNotPinnable)?;
716 let change = updater.get_changes();
717 editor.apply_or_diff(&change, state)?;
718 if !state.diff {
719 println!("Pinned input: {} to {}", id, target_rev);
720 }
721 } else if state.interactive {
722 if input_ids.is_empty() {
723 return Err(CommandError::NoInputs);
724 }
725 let lock = load_flake_lock(state).map_err(|e| CommandError::LockFileError {
726 path: state
727 .lock_file
728 .as_ref()
729 .map(|p| p.display().to_string())
730 .unwrap_or_else(|| "flake.lock".to_string()),
731 source: e,
732 })?;
733
734 interactive_single_select(
735 editor,
736 state,
737 "Pin",
738 "Select input",
739 input_ids,
740 |id| {
741 let target_rev = lock
742 .rev_for(id)
743 .map_err(|_| CommandError::InputNotFound(id.to_string()))?;
744 let mut updater = updater(editor, inputs.clone());
745 updater
746 .pin_input_to_ref(id, &target_rev)
747 .map_err(CommandError::InputNotPinnable)?;
748 Ok((updater.get_changes(), target_rev))
749 },
750 |id, target_rev| println!("Pinned input: {} to {}", id, target_rev),
751 )?;
752 } else {
753 return Err(CommandError::NoId);
754 }
755
756 Ok(())
757}
758
759pub fn unpin(
760 editor: &Editor,
761 flake_edit: &mut FlakeEdit,
762 state: &AppState,
763 id: Option<String>,
764) -> Result<()> {
765 let inputs = flake_edit.list().clone();
766 let input_ids = sorted_input_ids_owned(&inputs);
767
768 if let Some(id) = id {
769 let mut updater = updater(editor, inputs);
770 updater
771 .unpin_input(&id)
772 .map_err(CommandError::InputNotPinnable)?;
773 let change = updater.get_changes();
774 editor.apply_or_diff(&change, state)?;
775 if !state.diff {
776 println!("Unpinned input: {}", id);
777 }
778 } else if state.interactive {
779 if input_ids.is_empty() {
780 return Err(CommandError::NoInputs);
781 }
782
783 interactive_single_select(
784 editor,
785 state,
786 "Unpin",
787 "Select input",
788 input_ids,
789 |id| {
790 let mut updater = updater(editor, inputs.clone());
791 updater
792 .unpin_input(id)
793 .map_err(CommandError::InputNotPinnable)?;
794 Ok((updater.get_changes(), ()))
795 },
796 |id, ()| println!("Unpinned input: {}", id),
797 )?;
798 } else {
799 return Err(CommandError::NoId);
800 }
801
802 Ok(())
803}
804
805pub fn list(flake_edit: &mut FlakeEdit, format: &crate::cli::ListFormat) -> Result<()> {
806 let inputs = flake_edit.list();
807 crate::app::handler::list_inputs(inputs, format);
808 Ok(())
809}
810
811pub fn config(print_default: bool, path: bool) -> Result<()> {
813 use crate::config::{Config, DEFAULT_CONFIG_TOML};
814
815 if print_default {
816 print!("{}", DEFAULT_CONFIG_TOML);
817 return Ok(());
818 }
819
820 if path {
821 let project_path = Config::project_config_path();
823 let user_path = Config::user_config_path();
824
825 if let Some(path) = &project_path {
826 println!("Project config: {}", path.display());
827 }
828 if let Some(path) = &user_path {
829 println!("User config: {}", path.display());
830 }
831
832 if project_path.is_none() && user_path.is_none() {
833 if let Some(user_dir) = Config::user_config_dir() {
834 println!("No config found. Create one at:");
835 println!(" Project: flake-edit.toml (in current directory)");
836 println!(" User: {}/config.toml", user_dir.display());
837 } else {
838 println!("No config found. Create flake-edit.toml in current directory.");
839 }
840 }
841 return Ok(());
842 }
843
844 Ok(())
845}
846
847pub fn add_follow(
849 editor: &Editor,
850 flake_edit: &mut FlakeEdit,
851 state: &AppState,
852 input: Option<String>,
853 target: Option<String>,
854) -> Result<()> {
855 let change = if let (Some(input_val), Some(target_val)) = (input.clone(), target) {
856 Change::Follows {
858 input: input_val.into(),
859 target: target_val,
860 }
861 } else if state.interactive {
862 let Some(ctx) = load_follow_context(flake_edit, state)? else {
864 return Ok(());
865 };
866 let top_level_vec: Vec<String> = ctx.top_level_inputs.into_iter().collect();
867
868 let tui_app = if let Some(input_val) = input {
869 tui::App::follow_target("Follow", editor.text(), input_val, top_level_vec)
870 } else {
871 tui::App::follow("Follow", editor.text(), ctx.nested_inputs, top_level_vec)
872 };
873
874 let Some(tui::AppResult::Change(tui_change)) = tui::run(tui_app)? else {
875 return Ok(());
876 };
877 tui_change
878 } else {
879 return Err(CommandError::NoId);
880 };
881
882 apply_change(editor, flake_edit, state, change)
883}
884
885fn strip_attr_quotes(s: &str) -> &str {
891 s.strip_prefix('"')
892 .and_then(|s| s.strip_suffix('"'))
893 .unwrap_or(s)
894}
895
896fn normalize_nested_path(path: &str) -> String {
899 let (parent, child) = split_quoted_path(path).unwrap_or((path, path));
900 let p = strip_attr_quotes(parent);
901 let c = strip_attr_quotes(child);
902 let fmt_segment = |s: &str| -> String {
903 if s.contains('.') {
904 format!("\"{}\"", s)
905 } else {
906 s.to_string()
907 }
908 };
909 format!("{}.{}", fmt_segment(p), fmt_segment(c))
910}
911
912fn collect_stale_follows(
915 inputs: &InputMap,
916 existing_nested_paths: &HashSet<String>,
917) -> Vec<String> {
918 let normalized_existing: HashSet<String> = existing_nested_paths
919 .iter()
920 .map(|p| normalize_nested_path(p))
921 .collect();
922 let mut stale = Vec::new();
923 for (input_id, input) in inputs {
924 for follows in input.follows() {
925 if let Follows::Indirect(nested_name, _target) = follows {
926 let nested_path = format!("{}.{}", input_id, nested_name);
927 if !normalized_existing.contains(&normalize_nested_path(&nested_path)) {
928 stale.push(nested_path);
929 }
930 }
931 }
932 }
933 stale
934}
935
936pub fn follow_auto(editor: &Editor, flake_edit: &mut FlakeEdit, state: &AppState) -> Result<()> {
950 follow_auto_impl(editor, flake_edit, state, false)
951}
952
953fn follow_auto_impl(
955 editor: &Editor,
956 flake_edit: &mut FlakeEdit,
957 state: &AppState,
958 quiet: bool,
959) -> Result<()> {
960 let Some(ctx) = load_follow_context(flake_edit, state)? else {
961 if !quiet {
962 println!("Nothing to deduplicate.");
963 }
964 return Ok(());
965 };
966
967 let existing_nested_paths: HashSet<String> = load_flake_lock(state)
968 .map(|l| l.nested_input_paths().into_iter().collect())
969 .unwrap_or_default();
970
971 let to_unfollow = collect_stale_follows(&ctx.inputs, &existing_nested_paths);
972
973 let follow_config = &state.config.follow;
974 let existing_follows = collect_existing_follows(&ctx.inputs);
975 let transitive_min = follow_config.transitive_min();
976 let mut seen_nested: HashSet<String> = HashSet::new();
977
978 let mut to_follow: Vec<(String, String)> = ctx
980 .nested_inputs
981 .iter()
982 .filter_map(|nested| {
983 let (parent, nested_name) =
984 split_quoted_path(&nested.path).unwrap_or((&nested.path, &nested.path));
985
986 if follow_config.is_ignored(&nested.path, nested_name) {
988 tracing::debug!("Skipping {}: ignored by config", nested.path);
989 return None;
990 }
991
992 if existing_follows.contains(&normalize_nested_path(&nested.path)) {
994 tracing::debug!("Skipping {}: already follows in flake.nix", nested.path);
995 return None;
996 }
997
998 let matching_top_level = ctx
1000 .top_level_inputs
1001 .iter()
1002 .find(|top| follow_config.can_follow(nested_name, top));
1003
1004 let target = matching_top_level?;
1005
1006 if let Some(target_input) = ctx.inputs.get(target.as_str())
1010 && is_follows_reference_to_parent(target_input.url(), parent)
1011 {
1012 tracing::debug!(
1013 "Skipping {} -> {}: would create cycle (target follows {}/...)",
1014 nested.path,
1015 target,
1016 parent
1017 );
1018 return None;
1019 }
1020
1021 Some((nested.path.clone(), target.clone()))
1022 })
1023 .collect();
1024
1025 for (nested_path, _target) in &to_follow {
1026 seen_nested.insert(nested_path.clone());
1027 }
1028
1029 let mut transitive_groups: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
1030
1031 if transitive_min > 0 {
1032 for nested in ctx.nested_inputs.iter() {
1033 let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
1034 let parent = nested.path.split('.').next().unwrap_or(&nested.path);
1035
1036 if follow_config.is_ignored(&nested.path, nested_name) {
1037 continue;
1038 }
1039
1040 if existing_follows.contains(&normalize_nested_path(&nested.path))
1041 || seen_nested.contains(&nested.path)
1042 {
1043 continue;
1044 }
1045
1046 let matching_top_level = ctx
1047 .top_level_inputs
1048 .iter()
1049 .find(|top| follow_config.can_follow(nested_name, top));
1050
1051 if matching_top_level.is_some() {
1052 continue;
1053 }
1054
1055 let Some(transitive_target) = nested.follows.as_ref() else {
1056 continue;
1057 };
1058
1059 if !transitive_target.contains('.') {
1061 continue;
1062 }
1063
1064 if transitive_target == nested_name {
1066 continue;
1067 }
1068
1069 let top_level_name = follow_config
1070 .resolve_alias(nested_name)
1071 .unwrap_or(nested_name)
1072 .to_string();
1073
1074 if ctx.top_level_inputs.contains(&top_level_name) {
1076 continue;
1077 }
1078
1079 if let Some(target_input) = ctx.inputs.get(transitive_target.as_str())
1081 && is_follows_reference_to_parent(target_input.url(), parent)
1082 {
1083 continue;
1084 }
1085
1086 transitive_groups
1087 .entry(top_level_name)
1088 .or_default()
1089 .entry(transitive_target.clone())
1090 .or_default()
1091 .push(nested.path.clone());
1092 }
1093 }
1094
1095 let mut direct_groups: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
1102
1103 if transitive_min > 0 {
1104 for nested in ctx.nested_inputs.iter() {
1105 let nested_name = nested.path.split('.').next_back().unwrap_or(&nested.path);
1106
1107 if nested.follows.is_some() {
1108 continue;
1109 }
1110
1111 if follow_config.is_ignored(&nested.path, nested_name) {
1112 continue;
1113 }
1114
1115 if existing_follows.contains(&normalize_nested_path(&nested.path))
1116 || seen_nested.contains(&nested.path)
1117 {
1118 continue;
1119 }
1120
1121 let matching_top_level = ctx
1122 .top_level_inputs
1123 .iter()
1124 .find(|top| follow_config.can_follow(nested_name, top));
1125
1126 if matching_top_level.is_some() {
1127 continue;
1128 }
1129
1130 let canonical_name = follow_config
1131 .resolve_alias(nested_name)
1132 .unwrap_or(nested_name)
1133 .to_string();
1134
1135 if ctx.top_level_inputs.contains(&canonical_name) {
1136 continue;
1137 }
1138
1139 direct_groups
1140 .entry(canonical_name)
1141 .or_default()
1142 .push((nested.path.clone(), nested.url.clone()));
1143 }
1144 }
1145
1146 let mut toplevel_follows: Vec<(String, String)> = Vec::new();
1147 let mut toplevel_adds: Vec<(String, String)> = Vec::new();
1148
1149 if transitive_min > 0 {
1150 for (top_name, targets) in transitive_groups {
1151 let mut eligible: Vec<(String, Vec<String>)> = targets
1152 .into_iter()
1153 .filter(|(_, paths)| paths.len() >= transitive_min)
1154 .collect();
1155
1156 if eligible.len() != 1 {
1157 continue;
1158 }
1159
1160 let (target_path, paths) = eligible.pop().unwrap();
1161 let follow_target = lock_follows_to_flake_target(&target_path);
1162
1163 if follow_target == top_name {
1164 continue;
1165 }
1166
1167 toplevel_follows.push((top_name.clone(), follow_target));
1168
1169 for path in paths {
1170 if seen_nested.insert(path.clone()) {
1171 to_follow.push((path, top_name.clone()));
1172 }
1173 }
1174 }
1175
1176 let mut direct_groups_sorted: Vec<_> = direct_groups.into_iter().collect();
1180 direct_groups_sorted.sort_by(|a, b| a.0.cmp(&b.0));
1181 for (canonical_name, mut entries) in direct_groups_sorted {
1182 if entries.len() < transitive_min {
1183 continue;
1184 }
1185
1186 entries.sort_by(|a, b| a.0.cmp(&b.0));
1187
1188 let url = entries.iter().find_map(|(_, u)| u.clone());
1189 let Some(url) = url else {
1190 continue;
1191 };
1192
1193 let can_follow = entries.iter().any(|(path, _)| {
1195 let change = Change::Follows {
1196 input: path.clone().into(),
1197 target: canonical_name.clone(),
1198 };
1199 FlakeEdit::from_text(&editor.text())
1200 .ok()
1201 .and_then(|mut fe| fe.apply_change(change).ok().flatten())
1202 .is_some()
1203 });
1204 if !can_follow {
1205 continue;
1206 }
1207
1208 toplevel_adds.push((canonical_name.clone(), url));
1209
1210 for (path, _) in &entries {
1211 if seen_nested.insert(path.clone()) {
1212 to_follow.push((path.clone(), canonical_name.clone()));
1213 }
1214 }
1215 }
1216 }
1217
1218 if to_follow.is_empty()
1219 && to_unfollow.is_empty()
1220 && toplevel_follows.is_empty()
1221 && toplevel_adds.is_empty()
1222 {
1223 if !quiet {
1224 println!("All inputs are already deduplicated.");
1225 }
1226 return Ok(());
1227 }
1228
1229 let mut current_text = editor.text();
1231 let mut applied: Vec<(&str, &str)> = Vec::new();
1232
1233 for (id, url) in &toplevel_adds {
1236 let change = Change::Add {
1237 id: Some(id.clone()),
1238 uri: Some(url.clone()),
1239 flake: true,
1240 };
1241
1242 let mut temp_flake_edit =
1243 FlakeEdit::from_text(¤t_text).map_err(CommandError::FlakeEdit)?;
1244
1245 match temp_flake_edit.apply_change(change) {
1246 Ok(Some(resulting_text)) => {
1247 let validation = validate::validate(&resulting_text);
1248 if validation.is_ok() {
1249 current_text = resulting_text;
1250 } else {
1251 for err in validation.errors {
1252 eprintln!("Error adding top-level input {}: {}", id, err);
1253 }
1254 }
1255 }
1256 Ok(None) => eprintln!("Could not add top-level input {}", id),
1257 Err(e) => eprintln!("Error adding top-level input {}: {}", id, e),
1258 }
1259 }
1260
1261 let mut follow_changes: Vec<(String, String)> = Vec::new();
1262 follow_changes.extend(toplevel_follows);
1263 follow_changes.extend(to_follow);
1264
1265 for (input_path, target) in &follow_changes {
1266 let change = Change::Follows {
1267 input: input_path.clone().into(),
1268 target: target.clone(),
1269 };
1270
1271 let mut temp_flake_edit =
1272 FlakeEdit::from_text(¤t_text).map_err(CommandError::FlakeEdit)?;
1273
1274 match temp_flake_edit.apply_change(change) {
1275 Ok(Some(resulting_text)) => {
1276 if resulting_text == current_text {
1277 continue;
1279 }
1280 let validation = validate::validate(&resulting_text);
1281 if validation.is_ok() {
1282 current_text = resulting_text;
1283 applied.push((input_path, target));
1284 } else {
1285 for err in validation.errors {
1286 eprintln!("Error applying follows for {}: {}", input_path, err);
1287 }
1288 }
1289 }
1290 Ok(None) => eprintln!("Could not create follows for {}", input_path),
1291 Err(e) => eprintln!("Error applying follows for {}: {}", input_path, e),
1292 }
1293 }
1294
1295 let mut unfollowed: Vec<&str> = Vec::new();
1296
1297 for nested_path in &to_unfollow {
1298 let change = Change::Remove {
1299 ids: vec![nested_path.clone().into()],
1300 };
1301
1302 let mut temp_flake_edit =
1303 FlakeEdit::from_text(¤t_text).map_err(CommandError::FlakeEdit)?;
1304
1305 match temp_flake_edit.apply_change(change) {
1306 Ok(Some(resulting_text)) => {
1307 let validation = validate::validate(&resulting_text);
1308 if validation.is_ok() {
1309 current_text = resulting_text;
1310 unfollowed.push(nested_path);
1311 }
1312 }
1313 Ok(None) => {}
1314 Err(e) => eprintln!("Error removing stale follows for {}: {}", nested_path, e),
1315 }
1316 }
1317
1318 if applied.is_empty() && unfollowed.is_empty() {
1319 return Ok(());
1320 }
1321
1322 if state.diff {
1323 let original = editor.text();
1324 let diff = crate::diff::Diff::new(&original, ¤t_text);
1325 diff.compare();
1326 } else {
1327 editor.apply_or_diff(¤t_text, state)?;
1328
1329 if !quiet {
1330 if !applied.is_empty() {
1331 println!(
1332 "Deduplicated {} {}.",
1333 applied.len(),
1334 if applied.len() == 1 {
1335 "input"
1336 } else {
1337 "inputs"
1338 }
1339 );
1340 for (input_path, target) in &applied {
1341 if input_path.contains('.') {
1342 let (parent, nested_name) =
1343 split_quoted_path(input_path).unwrap_or((input_path, input_path));
1344 println!(" {}.{} → {}", parent, nested_name, target);
1345 } else {
1346 println!(" {} → {}", input_path, target);
1347 }
1348 }
1349 }
1350
1351 if !unfollowed.is_empty() {
1352 println!(
1353 "Removed {} stale follows {}.",
1354 unfollowed.len(),
1355 if unfollowed.len() == 1 {
1356 "declaration"
1357 } else {
1358 "declarations"
1359 }
1360 );
1361 for path in &unfollowed {
1362 println!(" {} (input no longer exists)", path);
1363 }
1364 }
1365 }
1366 }
1367
1368 Ok(())
1369}
1370
1371pub fn follow_auto_batch(
1377 paths: &[std::path::PathBuf],
1378 transitive: Option<usize>,
1379 args: &crate::cli::CliArgs,
1380) -> Result<()> {
1381 use std::path::PathBuf;
1382
1383 let mut errors: Vec<(PathBuf, CommandError)> = Vec::new();
1384
1385 for flake_path in paths {
1386 let lock_path = flake_path
1387 .parent()
1388 .map(|p| p.join("flake.lock"))
1389 .unwrap_or_else(|| PathBuf::from("flake.lock"));
1390
1391 let editor = match Editor::from_path(flake_path.clone()) {
1392 Ok(e) => e,
1393 Err(e) => {
1394 errors.push((flake_path.clone(), e.into()));
1395 continue;
1396 }
1397 };
1398
1399 let mut flake_edit = match editor.create_flake_edit() {
1400 Ok(fe) => fe,
1401 Err(e) => {
1402 errors.push((flake_path.clone(), e.into()));
1403 continue;
1404 }
1405 };
1406
1407 let mut state = match AppState::new(
1408 editor.text(),
1409 flake_path.clone(),
1410 args.config().map(PathBuf::from),
1411 ) {
1412 Ok(s) => s
1413 .with_diff(args.diff())
1414 .with_no_lock(args.no_lock())
1415 .with_interactive(false)
1416 .with_lock_file(Some(lock_path))
1417 .with_no_cache(args.no_cache())
1418 .with_cache_path(args.cache().map(PathBuf::from)),
1419 Err(e) => {
1420 errors.push((flake_path.clone(), e.into()));
1421 continue;
1422 }
1423 };
1424
1425 if let Some(min) = transitive {
1426 state.config.follow.transitive_min = min;
1427 }
1428
1429 if let Err(e) = follow_auto_impl(&editor, &mut flake_edit, &state, true) {
1430 errors.push((flake_path.clone(), e));
1431 }
1432 }
1433
1434 if errors.is_empty() {
1435 Ok(())
1436 } else {
1437 for (path, err) in &errors {
1438 eprintln!("Error processing {}: {}", path.display(), err);
1439 }
1440 Err(errors.into_iter().next().unwrap().1)
1442 }
1443}
1444
1445fn apply_change(
1446 editor: &Editor,
1447 flake_edit: &mut FlakeEdit,
1448 state: &AppState,
1449 change: Change,
1450) -> Result<()> {
1451 let original_content = flake_edit.source_text();
1452 match flake_edit.apply_change(change.clone()) {
1453 Ok(Some(resulting_change)) => {
1454 if change.is_follows() && resulting_change == original_content {
1455 if let Some(id) = change.id() {
1456 println!(
1457 "Already follows: {}.inputs.{}.follows = \"{}\"",
1458 id.input(),
1459 id.follows().unwrap_or("?"),
1460 change.follows_target().unwrap_or(&"?".to_string())
1461 );
1462 }
1463 return Ok(());
1464 }
1465
1466 let validation = validate::validate(&resulting_change);
1467 if validation.has_errors() {
1468 eprintln!("There are errors in the changes:");
1469 for e in &validation.errors {
1470 tracing::error!("Error: {e}");
1471 }
1472 eprintln!("{}", resulting_change);
1473 eprintln!("There were errors in the changes, the changes have not been applied.");
1474 std::process::exit(1);
1475 }
1476
1477 editor.apply_or_diff(&resulting_change, state)?;
1478
1479 if !state.diff {
1480 if let Change::Add {
1482 id: Some(id),
1483 uri: Some(uri),
1484 ..
1485 } = &change
1486 {
1487 let mut cache = crate::cache::Cache::load();
1488 cache.add_entry(id.clone(), uri.clone());
1489 if let Err(e) = cache.commit() {
1490 tracing::debug!("Could not write to cache: {}", e);
1491 }
1492 }
1493
1494 for msg in change.success_messages() {
1495 println!("{}", msg);
1496 }
1497 }
1498 }
1499 Err(e) => {
1500 return Err(e.into());
1501 }
1502 Ok(None) => {
1503 if change.is_remove() {
1504 return Err(CommandError::CouldNotRemove(
1505 change.id().map(|id| id.to_string()).unwrap_or_default(),
1506 ));
1507 }
1508 if change.is_follows() {
1509 let id = change.id().map(|id| id.to_string()).unwrap_or_default();
1510 eprintln!("The follows relationship for {} could not be created.", id);
1511 eprintln!(
1512 "\nPlease check that the input exists in the flake.nix file.\n\
1513 Use dot notation: `flake-edit follow <input>.<nested-input> <target>`\n\
1514 Example: `flake-edit follow rust-overlay.nixpkgs nixpkgs`"
1515 );
1516 std::process::exit(1);
1517 }
1518 println!("Nothing changed.");
1519 }
1520 }
1521
1522 Ok(())
1523}
1524
1525#[cfg(test)]
1526mod tests {
1527 use super::*;
1528
1529 #[test]
1530 fn test_is_follows_reference_to_parent() {
1531 assert!(is_follows_reference_to_parent(
1534 "\"clan-core/treefmt-nix\"",
1535 "clan-core"
1536 ));
1537
1538 assert!(is_follows_reference_to_parent(
1540 "clan-core/treefmt-nix",
1541 "clan-core"
1542 ));
1543
1544 assert!(is_follows_reference_to_parent(
1546 "\"some-input/nixpkgs\"",
1547 "some-input"
1548 ));
1549
1550 assert!(!is_follows_reference_to_parent(
1552 "\"github:nixos/nixpkgs\"",
1553 "clan-core"
1554 ));
1555
1556 assert!(!is_follows_reference_to_parent(
1558 "\"github:foo/clan-core-utils\"",
1559 "clan-core"
1560 ));
1561
1562 assert!(!is_follows_reference_to_parent(
1564 "\"clan-core-extended\"",
1565 "clan-core"
1566 ));
1567
1568 assert!(!is_follows_reference_to_parent("", "clan-core"));
1570
1571 assert!(!is_follows_reference_to_parent("\"\"", "clan-core"));
1573 }
1574
1575 #[test]
1576 fn test_strip_attr_quotes() {
1577 assert_eq!(strip_attr_quotes("\"nixpkgs\""), "nixpkgs");
1578 assert_eq!(strip_attr_quotes("nixpkgs"), "nixpkgs");
1579 assert_eq!(strip_attr_quotes("\"hls-1.10\""), "hls-1.10");
1580 assert_eq!(strip_attr_quotes(""), "");
1581 }
1582
1583 #[test]
1584 fn test_normalize_nested_path() {
1585 assert_eq!(normalize_nested_path("crane.nixpkgs"), "crane.nixpkgs");
1587
1588 assert_eq!(
1590 normalize_nested_path("\"home-manager\".nixpkgs"),
1591 "home-manager.nixpkgs"
1592 );
1593 assert_eq!(
1594 normalize_nested_path("\"home-manager\".\"nixpkgs\""),
1595 "home-manager.nixpkgs"
1596 );
1597
1598 assert_eq!(
1600 normalize_nested_path("\"hls-1.10\".nixpkgs"),
1601 "\"hls-1.10\".nixpkgs"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_collect_stale_follows_quoted_attrs() {
1607 use crate::input::Input;
1608
1609 let mut inputs = InputMap::new();
1612 let mut hm_input = Input::new("\"home-manager\"".to_string());
1613 hm_input.follows.push(Follows::Indirect(
1614 "nixpkgs".to_string(),
1615 "nixpkgs".to_string(),
1616 ));
1617 inputs.insert("\"home-manager\"".to_string(), hm_input);
1618
1619 let existing: HashSet<String> = ["home-manager.nixpkgs".to_string()].into_iter().collect();
1621
1622 let stale = collect_stale_follows(&inputs, &existing);
1623 assert!(
1624 stale.is_empty(),
1625 "Expected no stale follows, but got: {:?}",
1626 stale
1627 );
1628 }
1629}