Skip to main content

flake_edit/app/
commands.rs

1use ropey::Rope;
2
3use crate::change::Change;
4use crate::edit::{FlakeEdit, InputMap};
5use crate::error::Error as FlakeError;
6use crate::forge::update::Updater;
7use crate::lock::FlakeLock;
8use crate::tui;
9use crate::validate;
10
11use super::editor::Editor;
12use super::error::{Error, Result};
13use super::state::AppState;
14
15mod add;
16mod change;
17mod config;
18pub mod follow;
19pub mod list;
20mod pin;
21mod remove;
22mod update;
23mod uri;
24
25pub use add::add;
26pub use change::change;
27pub use config::config;
28pub use list::list;
29pub use pin::{pin, unpin};
30pub use remove::remove;
31pub use update::update;
32pub use uri::UriOptions;
33
34pub(super) fn updater(editor: &Editor, inputs: InputMap) -> Updater {
35    Updater::new(Rope::from_str(&editor.text()), inputs)
36}
37
38/// Load `flake.lock`, using the path from `state` if provided.
39pub(super) fn load_flake_lock(state: &AppState) -> std::result::Result<FlakeLock, FlakeError> {
40    if let Some(lock_path) = &state.lock_file {
41        FlakeLock::from_file(lock_path)
42    } else {
43        FlakeLock::from_default_path()
44    }
45}
46
47/// Outcome of [`confirm_or_apply`].
48enum ConfirmResult {
49    /// Change was applied successfully.
50    Applied,
51    /// User cancelled (Escape or window closed).
52    Cancelled,
53    /// User wants to go back to selection.
54    Back,
55}
56
57/// Interactive single-select loop with confirmation.
58///
59/// 1. Show selection screen
60/// 2. User selects an item
61/// 3. Build a change from the selection
62/// 4. Show confirmation (with diff if requested)
63/// 5. Apply or go back
64pub(super) fn interactive_single_select<F, OnApplied, ExtraData>(
65    editor: &Editor,
66    state: &AppState,
67    title: &str,
68    prompt: &str,
69    items: Vec<String>,
70    make_change: F,
71    on_applied: OnApplied,
72) -> Result<()>
73where
74    F: Fn(&str) -> Result<(String, ExtraData)>,
75    OnApplied: Fn(&str, ExtraData),
76{
77    loop {
78        let select_app = tui::App::select_one(title, prompt, items.clone(), state.diff);
79        let Some(tui::AppResult::SingleSelect(result)) = tui::run(select_app)? else {
80            return Ok(());
81        };
82        let tui::SingleSelectResult {
83            item: id,
84            show_diff,
85        } = result;
86        let (change, extra_data) = make_change(&id)?;
87
88        match confirm_or_apply(editor, state, title, &change, show_diff)? {
89            ConfirmResult::Applied => {
90                on_applied(&id, extra_data);
91                break;
92            }
93            ConfirmResult::Back => continue,
94            ConfirmResult::Cancelled => return Ok(()),
95        }
96    }
97    Ok(())
98}
99
100/// Multi-select counterpart of [`interactive_single_select`].
101pub(super) fn interactive_multi_select<F>(
102    editor: &Editor,
103    state: &AppState,
104    title: &str,
105    prompt: &str,
106    items: Vec<String>,
107    make_change: F,
108) -> Result<()>
109where
110    F: Fn(&[String]) -> String,
111{
112    loop {
113        let select_app = tui::App::select_many(title, prompt, items.clone(), state.diff);
114        let Some(tui::AppResult::MultiSelect(result)) = tui::run(select_app)? else {
115            return Ok(());
116        };
117        let tui::MultiSelectResultData {
118            items: selected,
119            show_diff,
120        } = result;
121        let change = make_change(&selected);
122
123        match confirm_or_apply(editor, state, title, &change, show_diff)? {
124            ConfirmResult::Applied => break,
125            ConfirmResult::Back => continue,
126            ConfirmResult::Cancelled => return Ok(()),
127        }
128    }
129    Ok(())
130}
131
132/// Run the confirm-or-apply workflow for a change.
133///
134/// If `show_diff` is true, shows a confirmation screen with the diff.
135/// Otherwise applies the change directly.
136fn confirm_or_apply(
137    editor: &Editor,
138    state: &AppState,
139    context: &str,
140    change: &str,
141    show_diff: bool,
142) -> Result<ConfirmResult> {
143    if show_diff || state.diff {
144        let diff = crate::diff::Diff::new(&editor.text(), change).to_string_plain();
145        let confirm_app = tui::App::confirm(context, &diff);
146        let Some(tui::AppResult::Confirm(action)) = tui::run(confirm_app)? else {
147            return Ok(ConfirmResult::Cancelled);
148        };
149        match action {
150            tui::ConfirmResultAction::Apply => {
151                let mut apply_state = state.clone();
152                apply_state.diff = false;
153                editor.apply_or_diff(change, &apply_state)?;
154                Ok(ConfirmResult::Applied)
155            }
156            tui::ConfirmResultAction::Back => Ok(ConfirmResult::Back),
157            tui::ConfirmResultAction::Exit => Ok(ConfirmResult::Cancelled),
158        }
159    } else {
160        editor.apply_or_diff(change, state)?;
161        Ok(ConfirmResult::Applied)
162    }
163}
164
165pub(super) fn apply_change(
166    editor: &Editor,
167    flake_edit: &mut FlakeEdit,
168    state: &AppState,
169    change: Change,
170) -> Result<()> {
171    let original_content = flake_edit.source_text();
172    let outcome = flake_edit.apply_change(change.clone())?;
173    let resulting_change = match outcome.text {
174        Some(t) => t,
175        None => {
176            if change.is_remove() {
177                let id = change
178                    .id()
179                    .expect("bug: Change::Remove always carries an id");
180                return Err(Error::CouldNotRemove { id });
181            }
182            if change.is_follows() {
183                let id = change.id().map(|id| id.to_string()).unwrap_or_default();
184                return Err(Error::FollowsCreateFailed { id });
185            }
186            println!("Nothing changed.");
187            return Ok(());
188        }
189    };
190
191    if change.is_follows() && resulting_change == original_content {
192        if let Some(id) = change.id() {
193            let follows_str = id
194                .follows()
195                .map(|s| s.render())
196                .unwrap_or_else(|| "?".to_string());
197            let target_str = change
198                .follows_target()
199                .map(|t| t.to_string())
200                .unwrap_or_else(|| "?".to_string());
201            println!(
202                "Already follows: {}.inputs.{}.follows = \"{}\"",
203                id.input().render(),
204                follows_str,
205                target_str,
206            );
207        }
208        return Ok(());
209    }
210
211    let validation = validate::validate(&resulting_change);
212    if validation.has_errors() {
213        for e in &validation.errors {
214            tracing::error!("validation error: {e}");
215        }
216        return Err(Error::ValidationAfterEdit(validation.errors));
217    }
218
219    editor.apply_or_diff(&resulting_change, state)?;
220
221    if !state.diff {
222        // Cache added entries for future completions.
223        if let Change::Add {
224            id: Some(id),
225            uri: Some(uri),
226            ..
227        } = &change
228        {
229            let mut cache = crate::cache::Cache::load();
230            cache.add_entry(id.to_string(), uri.clone());
231            if let Err(e) = cache.commit() {
232                tracing::debug!("Could not write to cache: {}", e);
233            }
234        }
235
236        for msg in change.success_messages() {
237            println!("{}", msg);
238        }
239    }
240
241    Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246    use std::collections::HashSet;
247
248    use super::*;
249    use crate::follows::AttrPath;
250
251    #[test]
252    fn existing_follows_via_graph_handles_quoted_attrs() {
253        use crate::follows::{FollowsGraph, Segment};
254        use crate::input::{Follows, Input};
255
256        // `"home-manager".inputs.nixpkgs.follows = "nixpkgs"` must resolve to
257        // a typed-AttrPath edge sourced at `home-manager.nixpkgs`, not at the
258        // quoted form.
259        let mut inputs = InputMap::new();
260        let hm_seg = Segment::from_unquoted("home-manager").unwrap();
261        let mut hm_input = Input::new(hm_seg.clone());
262        hm_input.follows.push(Follows::Indirect {
263            path: AttrPath::new(Segment::from_unquoted("nixpkgs").unwrap()),
264            target: Some(AttrPath::parse("nixpkgs").unwrap()),
265        });
266        inputs.insert("home-manager".to_string(), hm_input);
267
268        let graph = FollowsGraph::from_declared(&inputs);
269        let sources: HashSet<AttrPath> = graph.edges().map(|e| e.source.clone()).collect();
270        assert!(
271            sources.contains(&AttrPath::parse("home-manager.nixpkgs").unwrap()),
272            "expected typed-AttrPath edge sourced at `home-manager.nixpkgs`, got {sources:?}",
273        );
274    }
275}