Skip to main content

sley_submodule/
operations.rs

1//! Typed submodule administration plans for embedders.
2
3use sley_config::{ConfigEntry, GitConfig};
4use std::fmt;
5
6/// One `.gitmodules` entry selected for `submodule init`.
7#[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/// A repository-config change produced by a submodule administration plan.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ConfigMutation {
19    Set {
20        name: String,
21        key: String,
22        value: String,
23    },
24}
25
26/// A newly registered submodule URL, retained for porcelain rendering.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct InitRegistration {
29    pub name: String,
30    pub path: String,
31    pub url: String,
32}
33
34/// The deterministic configuration and rendering outcome of `submodule init`.
35#[derive(Debug, Clone, PartialEq, Eq, Default)]
36pub struct InitPlan {
37    pub mutations: Vec<ConfigMutation>,
38    pub registrations: Vec<InitRegistration>,
39}
40
41impl InitPlan {
42    /// Whether applying this plan changes repository configuration.
43    pub fn changed(&self) -> bool {
44        !self.mutations.is_empty()
45    }
46}
47
48/// A semantic failure while planning `submodule init`.
49#[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
67/// Plan `submodule init` configuration without performing filesystem I/O.
68///
69/// `resolve_url` is injected because relative URL resolution depends on the
70/// embedding application's effective superproject remote configuration.
71pub 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
122/// Apply a previously validated init plan to an in-memory repository config.
123pub 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}