1use sley_config::{ConfigEntry, GitConfig};
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct InitCandidate {
9 pub name: String,
10 pub path: String,
11 pub url: Option<String>,
12 pub update: Option<String>,
13 pub currently_active: bool,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ConfigMutation {
19 Set {
20 name: String,
21 key: String,
22 value: String,
23 },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct InitRegistration {
29 pub name: String,
30 pub path: String,
31 pub url: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub struct InitPlan {
37 pub mutations: Vec<ConfigMutation>,
38 pub registrations: Vec<InitRegistration>,
39}
40
41impl InitPlan {
42 pub fn changed(&self) -> bool {
44 !self.mutations.is_empty()
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum InitPlanError {
51 MissingUrl { path: String },
52}
53
54impl fmt::Display for InitPlanError {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::MissingUrl { path } => write!(
58 formatter,
59 "no URL found for submodule path '{path}' in .gitmodules"
60 ),
61 }
62 }
63}
64
65impl std::error::Error for InitPlanError {}
66
67pub fn plan_init<F>(
72 config: &GitConfig,
73 candidates: &[InitCandidate],
74 mut resolve_url: F,
75) -> Result<InitPlan, InitPlanError>
76where
77 F: FnMut(&str) -> String,
78{
79 let mut shadow = config.clone();
80 let mut plan = InitPlan::default();
81 for candidate in candidates {
82 if !candidate.currently_active {
83 push_set(&mut shadow, &mut plan, &candidate.name, "active", "true");
84 }
85 if shadow
86 .get("submodule", Some(&candidate.name), "url")
87 .is_none()
88 {
89 let Some(url) = candidate.url.as_deref() else {
90 return Err(InitPlanError::MissingUrl {
91 path: candidate.path.clone(),
92 });
93 };
94 let url = resolve_url(url);
95 push_set(&mut shadow, &mut plan, &candidate.name, "url", &url);
96 plan.registrations.push(InitRegistration {
97 name: candidate.name.clone(),
98 path: candidate.path.clone(),
99 url,
100 });
101 }
102 if shadow
103 .get("submodule", Some(&candidate.name), "update")
104 .is_none()
105 && let Some(update) = candidate.update.as_deref()
106 {
107 push_set(&mut shadow, &mut plan, &candidate.name, "update", update);
108 }
109 }
110 Ok(plan)
111}
112
113fn push_set(shadow: &mut GitConfig, plan: &mut InitPlan, name: &str, key: &str, value: &str) {
114 set_submodule_value(shadow, name, key, value);
115 plan.mutations.push(ConfigMutation::Set {
116 name: name.to_string(),
117 key: key.to_string(),
118 value: value.to_string(),
119 });
120}
121
122pub fn apply_init_plan(config: &mut GitConfig, plan: &InitPlan) {
124 for mutation in &plan.mutations {
125 match mutation {
126 ConfigMutation::Set { name, key, value } => {
127 set_submodule_value(config, name, key, value);
128 }
129 }
130 }
131}
132
133fn set_submodule_value(config: &mut GitConfig, name: &str, key: &str, value: &str) {
134 if let Some(section) =
135 config.sections.iter_mut().rev().find(|section| {
136 section.name == "submodule" && section.subsection.as_deref() == Some(name)
137 })
138 {
139 if let Some(entry) = section
140 .entries
141 .iter_mut()
142 .find(|entry| entry.key.eq_ignore_ascii_case(key))
143 {
144 entry.value = Some(value.to_string());
145 } else {
146 section
147 .entries
148 .push(ConfigEntry::new(key, Some(value.to_string())));
149 }
150 } else {
151 config.sections.push(sley_config::ConfigSection::new(
152 "submodule",
153 Some(name.to_string()),
154 vec![ConfigEntry::new(key, Some(value.to_string()))],
155 ));
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn init_plan_keeps_independent_active_url_and_update_guards() {
165 let config =
166 GitConfig::parse(b"[submodule \"module\"]\n\turl = existing\n").expect("parse config");
167 let plan = plan_init(
168 &config,
169 &[InitCandidate {
170 name: "module".into(),
171 path: "libs/module".into(),
172 url: Some("ignored".into()),
173 update: Some("merge".into()),
174 currently_active: false,
175 }],
176 str::to_string,
177 )
178 .expect("plan init");
179 assert_eq!(plan.registrations, Vec::new());
180 assert_eq!(plan.mutations.len(), 2);
181 let mut applied = config;
182 apply_init_plan(&mut applied, &plan);
183 assert_eq!(
184 applied.get("submodule", Some("module"), "active"),
185 Some("true")
186 );
187 assert_eq!(
188 applied.get("submodule", Some("module"), "update"),
189 Some("merge")
190 );
191 }
192
193 #[test]
194 fn init_plan_returns_typed_missing_url() {
195 let error = plan_init(
196 &GitConfig::default(),
197 &[InitCandidate {
198 name: "module".into(),
199 path: "module".into(),
200 url: None,
201 update: None,
202 currently_active: false,
203 }],
204 str::to_string,
205 )
206 .expect_err("missing URL");
207 assert_eq!(
208 error,
209 InitPlanError::MissingUrl {
210 path: "module".into()
211 }
212 );
213 }
214
215 #[test]
216 fn init_plan_resolves_and_reports_new_registration() {
217 let config = GitConfig::default();
218 let plan = plan_init(
219 &config,
220 &[InitCandidate {
221 name: "module".into(),
222 path: "libs/module".into(),
223 url: Some("../module".into()),
224 update: None,
225 currently_active: true,
226 }],
227 |url| format!("https://example.test/{url}"),
228 )
229 .expect("plan init");
230 assert_eq!(
231 plan.registrations,
232 vec![InitRegistration {
233 name: "module".into(),
234 path: "libs/module".into(),
235 url: "https://example.test/../module".into(),
236 }]
237 );
238 assert_eq!(
239 config.get("submodule", Some("module"), "url"),
240 None,
241 "planning must not mutate the caller's config"
242 );
243 let mut applied = config;
244 apply_init_plan(&mut applied, &plan);
245 assert_eq!(
246 applied.get("submodule", Some("module"), "url"),
247 Some("https://example.test/../module")
248 );
249 }
250}