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 flake: bool,
13 },
14 Remove {
15 ids: Vec<ChangeId>,
16 },
17 Change {
18 id: Option<ChangeId>,
19 uri: Option<String>,
20 },
21 Follows {
26 input: ChangeId,
29 target: AttrPath,
31 },
32}
33
34#[derive(Debug, Clone, PartialEq, serde::Serialize)]
39pub struct ChangeId(AttrPath);
40
41impl ChangeId {
42 pub fn new(path: AttrPath) -> Self {
43 ChangeId(path)
44 }
45
46 pub fn parse(s: &str) -> Result<Self, AttrPathParseError> {
49 Ok(ChangeId(AttrPath::parse(s)?))
50 }
51
52 pub fn path(&self) -> &AttrPath {
53 &self.0
54 }
55
56 pub fn input(&self) -> &Segment {
59 self.0.first()
60 }
61
62 pub fn follows(&self) -> Option<&Segment> {
64 self.0.child()
65 }
66
67 fn matches(&self, input: &Segment, follows: Option<&Segment>) -> bool {
68 if self.input() != input {
69 return false;
70 }
71 match (self.follows(), follows) {
72 (Some(self_follows), Some(f)) => self_follows == f,
73 (Some(_), None) => false,
74 (None, _) => true,
75 }
76 }
77
78 pub fn matches_with_follows(&self, input: &Segment, follows: Option<&Segment>) -> bool {
80 self.matches(input, follows)
81 }
82
83 pub fn matches_with_ctx(&self, follows: &Segment, ctx: Option<Context>) -> bool {
86 let ctx_input = ctx.and_then(|c| c.first().cloned());
87 match ctx_input {
88 Some(input) => self.matches(&input, Some(follows)),
89 None => self.input() == follows,
90 }
91 }
92}
93
94impl std::fmt::Display for ChangeId {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 write!(f, "{}", self.0)
97 }
98}
99
100impl TryFrom<String> for ChangeId {
101 type Error = AttrPathParseError;
102
103 fn try_from(value: String) -> Result<Self, Self::Error> {
104 ChangeId::parse(&value)
105 }
106}
107
108impl TryFrom<&str> for ChangeId {
109 type Error = AttrPathParseError;
110
111 fn try_from(value: &str) -> Result<Self, Self::Error> {
112 ChangeId::parse(value)
113 }
114}
115
116impl From<AttrPath> for ChangeId {
117 fn from(value: AttrPath) -> Self {
118 ChangeId(value)
119 }
120}
121
122impl From<Segment> for ChangeId {
123 fn from(value: Segment) -> Self {
124 ChangeId(AttrPath::new(value))
125 }
126}
127
128impl Change {
129 pub fn id(&self) -> Option<ChangeId> {
130 match self {
131 Change::None => None,
132 Change::Add { id, .. } => id.clone(),
133 Change::Remove { ids } => ids.first().cloned(),
134 Change::Change { id, .. } => id.clone(),
135 Change::Follows { input, .. } => Some(input.clone()),
136 }
137 }
138
139 pub fn ids(&self) -> Vec<ChangeId> {
140 match self {
141 Change::Remove { ids } => ids.clone(),
142 Change::Follows { input, .. } => vec![input.clone()],
143 _ => self.id().into_iter().collect(),
144 }
145 }
146 pub fn is_remove(&self) -> bool {
147 matches!(self, Change::Remove { .. })
148 }
149 pub fn is_follows(&self) -> bool {
150 matches!(self, Change::Follows { .. })
151 }
152 pub fn uri(&self) -> Option<&String> {
153 match self {
154 Change::Change { uri, .. } | Change::Add { uri, .. } => uri.as_ref(),
155 _ => None,
156 }
157 }
158 pub fn follows_target(&self) -> Option<&AttrPath> {
159 match self {
160 Change::Follows { target, .. } => Some(target),
161 _ => None,
162 }
163 }
164
165 pub fn success_messages(&self) -> Vec<String> {
166 match self {
167 Change::Add { id, uri, .. } => {
168 let id = id.as_ref().map(ChangeId::to_string);
169 vec![format!(
170 "Added input: {} = {}",
171 id.as_deref().unwrap_or("?"),
172 uri.as_deref().unwrap_or("?")
173 )]
174 }
175 Change::Remove { ids } => ids
176 .iter()
177 .map(|id| format!("Removed input: {}", id))
178 .collect(),
179 Change::Change { id, uri, .. } => {
180 let id = id.as_ref().map(ChangeId::to_string);
181 vec![format!(
182 "Changed input: {} -> {}",
183 id.as_deref().unwrap_or("?"),
184 uri.as_deref().unwrap_or("?")
185 )]
186 }
187 Change::Follows { input, target } => {
188 let segments = input.path().segments();
191 let path = if segments.len() == 1 {
192 format!("inputs.{}", segments[0].render())
193 } else {
194 let mut out = String::new();
195 for (i, seg) in segments.iter().enumerate() {
196 if i == 0 {
197 out.push_str(&seg.render());
198 } else {
199 out.push_str(".inputs.");
200 out.push_str(&seg.render());
201 }
202 }
203 out
204 };
205 vec![format!(
206 "Added follows: {}.follows = \"{}\"",
207 path,
208 target.to_flake_follows_string()
209 )]
210 }
211 Change::None => vec![],
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn change_id_quoted_dot() {
222 let id = ChangeId::parse("\"hls-1.10\".nixpkgs").unwrap();
223 assert_eq!(id.input().as_str(), "hls-1.10");
224 assert_eq!(id.follows().unwrap().as_str(), "nixpkgs");
225 }
226
227 #[test]
228 fn change_id_single_segment_no_follows() {
229 let id = ChangeId::parse("nixpkgs").unwrap();
230 assert_eq!(id.input().as_str(), "nixpkgs");
231 assert!(id.follows().is_none());
232 }
233
234 #[test]
235 fn success_message_depth_three_has_two_inputs_separators() {
236 let change = Change::Follows {
237 input: ChangeId::parse("neovim.nixvim.flake-parts").unwrap(),
238 target: AttrPath::parse("flake-parts").unwrap(),
239 };
240 let msgs = change.success_messages();
241 assert_eq!(msgs.len(), 1);
242 let msg = &msgs[0];
243 let inputs_count = msg.matches(".inputs.").count();
244 assert_eq!(
245 inputs_count, 2,
246 "depth-3 message should contain exactly two `.inputs.` separators, got: {msg}"
247 );
248 }
249
250 #[test]
251 fn success_message_depth_two_has_one_inputs_separator() {
252 let change = Change::Follows {
253 input: ChangeId::parse("crane.nixpkgs").unwrap(),
254 target: AttrPath::parse("nixpkgs").unwrap(),
255 };
256 let msgs = change.success_messages();
257 let msg = &msgs[0];
258 assert_eq!(msg.matches(".inputs.").count(), 1);
259 }
260
261 #[test]
262 fn success_message_depth_one_uses_inputs_prefix() {
263 let change = Change::Follows {
264 input: ChangeId::parse("nixpkgs").unwrap(),
265 target: AttrPath::parse("foo").unwrap(),
266 };
267 let msgs = change.success_messages();
268 let msg = &msgs[0];
269 assert!(
270 msg.starts_with("Added follows: inputs.nixpkgs.follows ="),
271 "depth-1 message should start with `inputs.<id>.follows =`, got: {msg}"
272 );
273 }
274}