Skip to main content

flake_edit/
change.rs

1use crate::follows::{AttrPath, AttrPathParseError, Segment};
2use crate::walk::Context;
3
4#[derive(Debug, Default, Clone, serde::Serialize)]
5pub enum Change {
6    #[default]
7    None,
8    Add {
9        id: Option<ChangeId>,
10        uri: Option<String>,
11        // Add an input as a flake.
12        flake: bool,
13    },
14    Remove {
15        ids: Vec<ChangeId>,
16    },
17    Change {
18        id: Option<ChangeId>,
19        uri: Option<String>,
20    },
21    /// Redirect a nested input to follow another input.
22    ///
23    /// Applying `Follows { input: "rust-overlay.nixpkgs", target: "nixpkgs" }`
24    /// writes `rust-overlay.inputs.nixpkgs.follows = "nixpkgs";`.
25    Follows {
26        /// Path to the nested input being redirected (e.g.
27        /// `rust-overlay.nixpkgs`).
28        input: ChangeId,
29        /// The input to follow.
30        target: AttrPath,
31    },
32    /// Make `uri` the active url of an input, keeping the previously
33    /// active url as a commented alternate on the adjacent line.
34    ///
35    /// When `uri` matches a stored alternate, that comment is uncommented
36    /// in place. Otherwise the new url is written directly below the
37    /// deactivated one. Lines never move, only the comment marker.
38    Toggle {
39        id: ChangeId,
40        /// The url to activate.
41        uri: String,
42        /// The url active before the toggle, for the success message.
43        previous: String,
44    },
45    /// Delete a stored variant's line from an input.
46    ///
47    /// When `uri` names a commented alternate, its line is deleted and the
48    /// active url stays put. When `uri` names the active url, `activate`
49    /// names the stored alternate that is uncommented in its place and the
50    /// previously active line is deleted rather than kept as a comment.
51    ToggleRemove {
52        id: ChangeId,
53        /// The variant whose line is removed.
54        uri: String,
55        /// The alternate activated when `uri` is the active url.
56        activate: Option<String>,
57    },
58}
59
60/// Identifier for an input or nested-input target of a [`Change`].
61///
62/// Wraps an [`AttrPath`]: a non-empty sequence of unquoted segments matching
63/// flake-side attribute path grammar.
64#[derive(Debug, Clone, PartialEq, serde::Serialize)]
65pub struct ChangeId(AttrPath);
66
67impl ChangeId {
68    pub fn new(path: AttrPath) -> Self {
69        ChangeId(path)
70    }
71
72    /// Parse a dotted attribute path. Quoted segments (e.g. `"hls-1.10"`)
73    /// retain dots that would otherwise act as separators.
74    pub fn parse(s: &str) -> Result<Self, AttrPathParseError> {
75        Ok(ChangeId(AttrPath::parse(s)?))
76    }
77
78    pub fn path(&self) -> &AttrPath {
79        &self.0
80    }
81
82    /// Top-level input segment: the segment before the first dot, or the whole
83    /// path if it has only one segment.
84    pub fn input(&self) -> &Segment {
85        self.0.first()
86    }
87
88    /// Second segment, if the path has more than one segment.
89    pub fn follows(&self) -> Option<&Segment> {
90        self.0.child()
91    }
92
93    fn matches(&self, input: &Segment, follows: Option<&Segment>) -> bool {
94        if self.input() != input {
95            return false;
96        }
97        match (self.follows(), follows) {
98            (Some(self_follows), Some(f)) => self_follows == f,
99            (Some(_), None) => false,
100            (None, _) => true,
101        }
102    }
103
104    /// Match against an explicit `(input, follows)` pair.
105    pub fn matches_with_follows(&self, input: &Segment, follows: Option<&Segment>) -> bool {
106        self.matches(input, follows)
107    }
108
109    /// Match against the surrounding walker [`Context`], which carries the
110    /// enclosing top-level input.
111    pub fn matches_with_ctx(&self, follows: &Segment, ctx: Option<Context>) -> bool {
112        let ctx_input = ctx.and_then(|c| c.first().cloned());
113        match ctx_input {
114            Some(input) => self.matches(&input, Some(follows)),
115            None => self.input() == follows,
116        }
117    }
118}
119
120impl std::fmt::Display for ChangeId {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        write!(f, "{}", self.0)
123    }
124}
125
126impl TryFrom<String> for ChangeId {
127    type Error = AttrPathParseError;
128
129    fn try_from(value: String) -> Result<Self, Self::Error> {
130        ChangeId::parse(&value)
131    }
132}
133
134impl TryFrom<&str> for ChangeId {
135    type Error = AttrPathParseError;
136
137    fn try_from(value: &str) -> Result<Self, Self::Error> {
138        ChangeId::parse(value)
139    }
140}
141
142impl From<AttrPath> for ChangeId {
143    fn from(value: AttrPath) -> Self {
144        ChangeId(value)
145    }
146}
147
148impl From<Segment> for ChangeId {
149    fn from(value: Segment) -> Self {
150        ChangeId(AttrPath::new(value))
151    }
152}
153
154impl Change {
155    pub fn id(&self) -> Option<ChangeId> {
156        match self {
157            Change::None => None,
158            Change::Add { id, .. } => id.clone(),
159            Change::Remove { ids } => ids.first().cloned(),
160            Change::Change { id, .. } => id.clone(),
161            Change::Follows { input, .. } => Some(input.clone()),
162            Change::Toggle { id, .. } | Change::ToggleRemove { id, .. } => Some(id.clone()),
163        }
164    }
165
166    pub fn ids(&self) -> Vec<ChangeId> {
167        match self {
168            Change::Remove { ids } => ids.clone(),
169            Change::Follows { input, .. } => vec![input.clone()],
170            _ => self.id().into_iter().collect(),
171        }
172    }
173    pub fn is_remove(&self) -> bool {
174        matches!(self, Change::Remove { .. })
175    }
176    pub fn is_follows(&self) -> bool {
177        matches!(self, Change::Follows { .. })
178    }
179    pub fn uri(&self) -> Option<&String> {
180        match self {
181            Change::Change { uri, .. } | Change::Add { uri, .. } => uri.as_ref(),
182            Change::Toggle { uri, .. } => Some(uri),
183            _ => None,
184        }
185    }
186    pub fn follows_target(&self) -> Option<&AttrPath> {
187        match self {
188            Change::Follows { target, .. } => Some(target),
189            _ => None,
190        }
191    }
192
193    pub fn success_messages(&self) -> Vec<String> {
194        match self {
195            Change::Add { id, uri, .. } => {
196                let id = id.as_ref().map(ChangeId::to_string);
197                vec![format!(
198                    "Added input: {} = {}",
199                    id.as_deref().unwrap_or("?"),
200                    uri.as_deref().unwrap_or("?")
201                )]
202            }
203            Change::Remove { ids } => ids
204                .iter()
205                .map(|id| format!("Removed input: {}", id))
206                .collect(),
207            Change::Change { id, uri, .. } => {
208                let id = id.as_ref().map(ChangeId::to_string);
209                vec![format!(
210                    "Changed input: {} -> {}",
211                    id.as_deref().unwrap_or("?"),
212                    uri.as_deref().unwrap_or("?")
213                )]
214            }
215            Change::Follows { input, target } => {
216                // Interleave segments with `.inputs.`: `[a, b, c]` renders as
217                // `a.inputs.b.inputs.c`. Length-1 paths get an `inputs.` prefix.
218                let segments = input.path().segments();
219                let path = if segments.len() == 1 {
220                    format!("inputs.{}", segments[0].render())
221                } else {
222                    let mut out = String::new();
223                    for (i, seg) in segments.iter().enumerate() {
224                        if i == 0 {
225                            out.push_str(&seg.render());
226                        } else {
227                            out.push_str(".inputs.");
228                            out.push_str(&seg.render());
229                        }
230                    }
231                    out
232                };
233                vec![format!(
234                    "Added follows: {}.follows = \"{}\"",
235                    path,
236                    target.to_flake_follows_string()
237                )]
238            }
239            Change::Toggle { id, uri, previous } => {
240                vec![format!("Toggled {}: {} -> {}", id, previous, uri)]
241            }
242            Change::ToggleRemove { id, uri, activate } => {
243                let removed = format!("Removed {} alternate: {}", id, uri);
244                match activate {
245                    Some(activate) => {
246                        vec![format!("Toggled {}: {} -> {}", id, uri, activate), removed]
247                    }
248                    None => vec![removed],
249                }
250            }
251            Change::None => vec![],
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn change_id_quoted_dot() {
262        let id = ChangeId::parse("\"hls-1.10\".nixpkgs").unwrap();
263        assert_eq!(id.input().as_str(), "hls-1.10");
264        assert_eq!(id.follows().unwrap().as_str(), "nixpkgs");
265    }
266
267    #[test]
268    fn change_id_single_segment_no_follows() {
269        let id = ChangeId::parse("nixpkgs").unwrap();
270        assert_eq!(id.input().as_str(), "nixpkgs");
271        assert!(id.follows().is_none());
272    }
273
274    #[test]
275    fn success_message_depth_three_has_two_inputs_separators() {
276        let change = Change::Follows {
277            input: ChangeId::parse("neovim.nixvim.flake-parts").unwrap(),
278            target: AttrPath::parse("flake-parts").unwrap(),
279        };
280        let msgs = change.success_messages();
281        assert_eq!(msgs.len(), 1);
282        let msg = &msgs[0];
283        let inputs_count = msg.matches(".inputs.").count();
284        assert_eq!(
285            inputs_count, 2,
286            "depth-3 message should contain exactly two `.inputs.` separators, got: {msg}"
287        );
288    }
289
290    #[test]
291    fn success_message_depth_two_has_one_inputs_separator() {
292        let change = Change::Follows {
293            input: ChangeId::parse("crane.nixpkgs").unwrap(),
294            target: AttrPath::parse("nixpkgs").unwrap(),
295        };
296        let msgs = change.success_messages();
297        let msg = &msgs[0];
298        assert_eq!(msg.matches(".inputs.").count(), 1);
299    }
300
301    #[test]
302    fn success_message_depth_one_uses_inputs_prefix() {
303        let change = Change::Follows {
304            input: ChangeId::parse("nixpkgs").unwrap(),
305            target: AttrPath::parse("foo").unwrap(),
306        };
307        let msgs = change.success_messages();
308        let msg = &msgs[0];
309        assert!(
310            msg.starts_with("Added follows: inputs.nixpkgs.follows ="),
311            "depth-1 message should start with `inputs.<id>.follows =`, got: {msg}"
312        );
313    }
314}