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