Skip to main content

mise_interactive_config/editor/
mod.rs

1//! Interactive TOML config editor
2//!
3//! This module provides the main editor interface for interactively editing
4//! mise configuration files.
5
6mod actions;
7mod handlers;
8mod undo;
9
10use std::io;
11use std::path::PathBuf;
12
13use crate::cursor::Cursor;
14use crate::document::{EntryValue, TomlDocument};
15use crate::providers::{
16    BackendProvider, EmptyBackendProvider, EmptyToolProvider, EmptyVersionProvider, ToolProvider,
17    VersionProvider,
18};
19use crate::render::{Mode, Renderer};
20
21use undo::UndoAction;
22
23/// Result of running the interactive config editor
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ConfigResult {
26    /// User saved changes (includes document content as TOML string)
27    Saved(String),
28    /// User quit without saving
29    Cancelled,
30}
31
32/// Interactive TOML config editor
33pub struct InteractiveConfig {
34    path: PathBuf,
35    pub(crate) doc: TomlDocument,
36    pub(crate) cursor: Cursor,
37    pub(crate) mode: Mode,
38    dry_run: bool,
39    title: String,
40    pub(crate) renderer: Renderer,
41    pub(crate) tool_provider: Box<dyn ToolProvider>,
42    pub(crate) version_provider: Box<dyn VersionProvider>,
43    pub(crate) backend_provider: Box<dyn BackendProvider>,
44    /// Preferred version specificity index (0=latest, 1=major, 2=major.minor, 3=full, etc.)
45    pub(crate) preferred_specificity: usize,
46    /// Undo stack for deleted items
47    pub(crate) undo_stack: Vec<UndoAction>,
48    /// Cached display path for rendering
49    pub(crate) path_display: String,
50}
51
52impl InteractiveConfig {
53    /// Create a new config editor for a new file with default sections
54    pub fn new(path: PathBuf) -> Self {
55        let path_display = Self::compute_path_display(&path);
56        Self {
57            path,
58            doc: TomlDocument::new(),
59            cursor: Cursor::new(),
60            mode: Mode::Navigate,
61            dry_run: false,
62            title: "mise config editor".to_string(),
63            renderer: Renderer::new(),
64            tool_provider: Box::new(EmptyToolProvider),
65            version_provider: Box::new(EmptyVersionProvider),
66            backend_provider: Box::new(EmptyBackendProvider),
67            preferred_specificity: 0, // Default to "latest"
68            undo_stack: Vec::new(),
69            path_display,
70        }
71    }
72
73    /// Open an existing config file for editing
74    pub fn open(path: PathBuf) -> io::Result<Self> {
75        let doc = if path.exists() {
76            TomlDocument::load(&path)?
77        } else {
78            TomlDocument::new()
79        };
80
81        // Infer preferred specificity from last tool in the file
82        let preferred_specificity = Self::infer_specificity_from_doc(&doc);
83        let path_display = Self::compute_path_display(&path);
84
85        Ok(Self {
86            path,
87            doc,
88            cursor: Cursor::new(),
89            mode: Mode::Navigate,
90            dry_run: false,
91            title: "mise config editor".to_string(),
92            renderer: Renderer::new(),
93            tool_provider: Box::new(EmptyToolProvider),
94            version_provider: Box::new(EmptyVersionProvider),
95            backend_provider: Box::new(EmptyBackendProvider),
96            preferred_specificity,
97            undo_stack: Vec::new(),
98            path_display,
99        })
100    }
101
102    /// Infer version specificity from the last tool in the document
103    fn infer_specificity_from_doc(doc: &TomlDocument) -> usize {
104        // Find the tools section
105        let tools_section = doc.sections.iter().find(|s| s.name == "tools");
106        if let Some(section) = tools_section {
107            // Get the last entry's version
108            if let Some(entry) = section.entries.last()
109                && let EntryValue::Simple(version) = &entry.value
110            {
111                return Self::version_to_specificity(version);
112            }
113        }
114        0 // Default to "latest"
115    }
116
117    /// Convert a version string to a specificity index
118    fn version_to_specificity(version: &str) -> usize {
119        if version == "latest" {
120            0
121        } else {
122            // Count dots to determine specificity
123            // "22" -> 1 (major)
124            // "22.13" -> 2 (major.minor)
125            // "22.13.1" -> 3 (full)
126            let dots = version.chars().filter(|c| *c == '.').count();
127            dots + 1
128        }
129    }
130
131    /// Compute the display path for rendering
132    fn compute_path_display(path: &std::path::Path) -> String {
133        let abs_path = if path.is_relative() {
134            std::env::current_dir()
135                .map(|cwd| cwd.join(path))
136                .unwrap_or_else(|_| path.to_path_buf())
137        } else {
138            path.to_path_buf()
139        };
140        xx::file::display_path(&abs_path)
141    }
142
143    /// Set dry-run mode (no file writes)
144    pub fn dry_run(mut self, dry_run: bool) -> Self {
145        self.dry_run = dry_run;
146        self
147    }
148
149    /// Set the title displayed in the header
150    pub fn title(mut self, title: &str) -> Self {
151        self.title = title.to_string();
152        self
153    }
154
155    /// Set the tool provider for the tool picker
156    pub fn with_tool_provider(mut self, provider: Box<dyn ToolProvider>) -> Self {
157        self.tool_provider = provider;
158        self
159    }
160
161    /// Set the version provider for version cycling
162    pub fn with_version_provider(mut self, provider: Box<dyn VersionProvider>) -> Self {
163        self.version_provider = provider;
164        self
165    }
166
167    /// Set the backend provider for the backend picker
168    pub fn with_backend_provider(mut self, provider: Box<dyn BackendProvider>) -> Self {
169        self.backend_provider = provider;
170        self
171    }
172
173    /// Add a tool to the tools section
174    pub fn add_tool(&mut self, name: &str, version: &str) {
175        if let Some(tools_idx) = self.doc.sections.iter().position(|s| s.name == "tools") {
176            // Check if tool already exists
177            if !self.doc.sections[tools_idx]
178                .entries
179                .iter()
180                .any(|e| e.key == name)
181            {
182                self.doc
183                    .add_entry(tools_idx, name.to_string(), version.to_string());
184            }
185        }
186    }
187
188    /// Add a prepare provider with auto = true
189    pub fn add_prepare(&mut self, provider: &str) {
190        // Find or create prepare section
191        let prepare_idx =
192            if let Some(idx) = self.doc.sections.iter().position(|s| s.name == "prepare") {
193                idx
194            } else {
195                // Insert prepare section before settings
196                let settings_idx = self.doc.sections.iter().position(|s| s.name == "settings");
197                let insert_idx = settings_idx.unwrap_or(self.doc.sections.len());
198                self.doc.sections.insert(
199                    insert_idx,
200                    crate::document::Section {
201                        name: "prepare".to_string(),
202                        entries: Vec::new(),
203                        expanded: false,
204                        comments: Vec::new(),
205                    },
206                );
207                insert_idx
208            };
209
210        // Check if provider already exists
211        if !self.doc.sections[prepare_idx]
212            .entries
213            .iter()
214            .any(|e| e.key == provider)
215        {
216            // Add as inline table with auto = true
217            self.doc.sections[prepare_idx]
218                .entries
219                .push(crate::document::Entry {
220                    key: provider.to_string(),
221                    value: EntryValue::InlineTable(vec![("auto".to_string(), "true".to_string())]),
222                    expanded: false,
223                    comments: Vec::new(),
224                });
225            self.doc.modified = true;
226        }
227    }
228
229    /// Run the interactive editor
230    pub async fn run(mut self) -> io::Result<ConfigResult> {
231        // Initial render
232        self.render_current_mode()?;
233
234        loop {
235            // Read key in blocking task to not block the async runtime
236            let term = self.renderer.term().clone();
237            let key = tokio::task::spawn_blocking(move || term.read_key())
238                .await
239                .map_err(io::Error::other)??;
240
241            let should_exit = self.handle_key(key).await?;
242            if let Some(result) = should_exit {
243                // Clear the display before returning
244                self.renderer.clear()?;
245                return Ok(result);
246            }
247
248            // Re-render
249            self.render_current_mode()?;
250        }
251    }
252
253    /// Render based on current mode
254    pub(crate) fn render_current_mode(&mut self) -> io::Result<()> {
255        match &self.mode {
256            Mode::Picker(kind, picker) => {
257                self.renderer
258                    .render_picker(picker, kind, &self.path_display)?;
259            }
260            Mode::Loading(message) => {
261                self.renderer
262                    .render_loading(message, &self.title, &self.path_display)?;
263            }
264            _ => {
265                self.renderer.render(
266                    &self.doc,
267                    &self.cursor,
268                    &self.mode,
269                    &self.title,
270                    &self.path_display,
271                    self.dry_run,
272                    !self.undo_stack.is_empty(),
273                )?;
274            }
275        }
276        Ok(())
277    }
278}