Skip to main content

gkit_core/
conf.rs

1//! Clone config — structured TOML.
2//!
3//! ```toml
4//! host      = "tlbb"
5//! namespace = "example-org"   # GitHub org / GitLab group / user; URL = host:namespace/repo.git
6//!
7//! # global (all optional; `namespace` too — a repo may set its own instead)
8//! git-flags   = ["-c", "http.lowSpeedLimit=1000"]   # raw, BEFORE `clone`
9//! clone-flags = ["--filter=blob:none"]              # raw, AFTER `clone`
10//! pre-clone   = "echo starting $GKIT_REPO"           # string OR list of strings
11//! post-clone  = ["direnv allow ."]
12//!
13//! [[repo]]
14//! dir = "$CP_HOME/cp-conf"
15//!
16//! [[repo]]
17//! dir         = "$CP_COMMON_LIBS/cosp"
18//! namespace   = "other-org"   # overrides the global namespace for THIS repo
19//! depth       = 1
20//! branch      = "dev"
21//! clone-flags = ["--no-tags"]
22//! post-clone  = ["mill compile"]
23//! ```
24//!
25//! `host` (and optionally `namespace`) live in the file (not the filename) → one
26//! ssh key can back many confs. A repo's effective namespace is its own
27//! `namespace`, else the global one; at least one must be present. gkit keeps no
28//! global state: this file + each repo's own metadata are the state.
29
30use serde::Deserialize;
31
32#[derive(Debug, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "kebab-case", deny_unknown_fields)]
34pub struct CloneConf {
35    pub host: String,
36    /// Global namespace (org/group/user). Optional — a repo may set its own; every
37    /// repo must resolve one (see [`CloneConf::validate`]).
38    #[serde(default)]
39    pub namespace: Option<String>,
40    /// Raw flags applied BEFORE `clone` (git-level, e.g. `-c k=v`).
41    #[serde(default)]
42    pub git_flags: Vec<String>,
43    /// Raw flags applied AFTER `clone` for every repo.
44    #[serde(default)]
45    pub clone_flags: Vec<String>,
46    /// Commands run before every repo's clone.
47    #[serde(default)]
48    pub pre_clone: Hooks,
49    /// Commands run after every repo's clone.
50    #[serde(default)]
51    pub post_clone: Hooks,
52    #[serde(default)]
53    pub repo: Vec<Repo>,
54}
55
56#[derive(Debug, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "kebab-case", deny_unknown_fields)]
58pub struct Repo {
59    /// Local destination dir (raw; `$VAR`/`~` expanded at clone time).
60    pub dir: String,
61    /// Per-repo namespace (org/group/user) — overrides the global `namespace` for
62    /// this repo. One of repo/global namespace must be set.
63    #[serde(default)]
64    pub namespace: Option<String>,
65    /// Remote repo name (the URL's last segment). Defaults to `basename(dir)`; set
66    /// this to clone a repo into a differently-named local directory.
67    #[serde(default)]
68    pub name: Option<String>,
69    #[serde(default)]
70    pub depth: Option<u32>,
71    #[serde(default)]
72    pub branch: Option<String>,
73    /// Per-repo raw flags AFTER `clone`.
74    #[serde(default)]
75    pub clone_flags: Vec<String>,
76    #[serde(default)]
77    pub pre_clone: Hooks,
78    #[serde(default)]
79    pub post_clone: Hooks,
80}
81
82impl CloneConf {
83    /// Effective namespace for a repo: its own `namespace`, else the global one.
84    pub fn namespace_for<'a>(&'a self, repo: &'a Repo) -> Option<&'a str> {
85        repo.namespace.as_deref().or(self.namespace.as_deref())
86    }
87
88    /// Every repo must resolve a namespace (per-repo or global). Returns an error
89    /// naming the offending dir(s) — call before cloning so nothing runs when a
90    /// namespace is missing.
91    pub fn validate(&self) -> Result<(), String> {
92        let missing: Vec<&str> = self
93            .repo
94            .iter()
95            .filter(|r| self.namespace_for(r).is_none())
96            .map(|r| r.dir.as_str())
97            .collect();
98        if missing.is_empty() {
99            Ok(())
100        } else {
101            Err(format!(
102                "no namespace for {} — set a global `namespace` or a per-repo `namespace`",
103                missing.join(", ")
104            ))
105        }
106    }
107}
108
109impl Repo {
110    /// Remote repo name (drives the clone URL): explicit `name`, else basename(dir).
111    pub fn name(&self) -> String {
112        self.name.clone().unwrap_or_else(|| {
113            self.dir
114                .trim_end_matches('/')
115                .rsplit('/')
116                .next()
117                .unwrap_or(&self.dir)
118                .to_string()
119        })
120    }
121}
122
123/// A hook field: TOML may give a single string or a list of strings.
124#[derive(Debug, Default, PartialEq, Eq)]
125pub struct Hooks(pub Vec<String>);
126
127impl<'de> Deserialize<'de> for Hooks {
128    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
129        #[derive(Deserialize)]
130        #[serde(untagged)]
131        enum OneOrMany {
132            One(String),
133            Many(Vec<String>),
134        }
135        Ok(match OneOrMany::deserialize(d)? {
136            OneOrMany::One(s) => Hooks(vec![s]),
137            OneOrMany::Many(v) => Hooks(v),
138        })
139    }
140}
141
142/// Parse the TOML clone config.
143pub fn parse(text: &str) -> Result<CloneConf, String> {
144    toml::from_str(text).map_err(|e| e.message().to_string())
145}
146
147/// Expand a leading `~` and `$VAR`/`${VAR}` using `get` (e.g. `|k| std::env::var(k).ok()`).
148/// Unset variables expand to empty (like a shell).
149pub fn expand_path(raw: &str, get: impl Fn(&str) -> Option<String>) -> String {
150    let mut s = raw.to_string();
151    if s == "~" {
152        return get("HOME").unwrap_or_default();
153    }
154    if let Some(rest) = s.strip_prefix("~/") {
155        s = format!("{}/{}", get("HOME").unwrap_or_default(), rest);
156    }
157    expand_vars(&s, get)
158}
159
160fn expand_vars(s: &str, get: impl Fn(&str) -> Option<String>) -> String {
161    let bytes = s.as_bytes();
162    let mut out = String::with_capacity(s.len());
163    let mut i = 0;
164    while i < bytes.len() {
165        if bytes[i] == b'$' {
166            let (name, next) = if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
167                match s[i + 2..].find('}').map(|e| i + 2 + e) {
168                    Some(e) => (&s[i + 2..e], e + 1),
169                    None => (&s[i + 1..i + 1], i + 1),
170                }
171            } else {
172                let mut j = i + 1;
173                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
174                    j += 1;
175                }
176                (&s[i + 1..j], j)
177            };
178            if name.is_empty() {
179                out.push('$');
180                i += 1;
181            } else {
182                out.push_str(&get(name).unwrap_or_default());
183                i = next;
184            }
185        } else {
186            out.push(bytes[i] as char);
187            i += 1;
188        }
189    }
190    out
191}
192
193/// Parse an scp-like `host:namespace/repo.git` URL into `(host, namespace)`.
194/// Returns `None` for `https://` or `user@host` forms (gkit uses ssh Host aliases),
195/// so `init` only pre-fills when it can do so cleanly.
196pub fn scp_url_parts(url: &str) -> Option<(String, String)> {
197    let url = url.trim();
198    if url.contains("://") || url.contains('@') {
199        return None;
200    }
201    let (host, path) = url.split_once(':')?;
202    let (namespace, _repo) = path.rsplit_once('/')?;
203    if host.is_empty() || namespace.is_empty() {
204        return None;
205    }
206    Some((host.to_string(), namespace.to_string()))
207}
208
209/// A starter clone config (sensible defaults + commented examples). `host`/
210/// `namespace` are pre-filled when known, else left as placeholders.
211pub fn template(host: Option<&str>, namespace: Option<&str>) -> String {
212    let host = host.unwrap_or("<ssh-host-alias>");
213    let namespace = namespace.unwrap_or("<namespace>");
214    format!(
215        r#"# gkit clone config — run `gkit clone <this-file>`.
216host      = "{host}"        # ssh Host alias (~/.ssh/config); URL = host:namespace/repo.git
217namespace = "{namespace}"   # GitHub org / GitLab group / user (optional — a repo may set its own)
218
219# `gkit.baseBranch` = this repo's integration branch. `gkit logoff` and `gkit stmb`
220# read it as the "base": the branch stmb returns to, and the one logoff checks
221# against. Stamped on every cloned repo here:
222post-clone = ["git config gkit.baseBranch main"]   # change to your convention: master / dev
223
224# More optional global settings (uncomment as needed):
225# git-flags   = ["-c", "http.lowSpeedLimit=1000"]   # raw flags BEFORE `clone`
226# clone-flags = ["--filter=blob:none"]              # raw flags AFTER `clone`
227# pre-clone   = "echo cloning $GKIT_REPO"
228
229# One [[repo]] block per repo (name = basename of dir; $VAR/~ expanded):
230[[repo]]
231dir = "$HOME/work/example"
232# namespace   = "other-org"   # override the global namespace for THIS repo
233# name        = "example"     # remote repo name if it differs from the dir basename
234# depth       = 1
235# branch      = "dev"
236# clone-flags = ["--no-tags"]
237# post-clone  = ["mill compile"]
238"#
239    )
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::collections::HashMap;
246
247    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
248        let m: HashMap<String, String> = pairs
249            .iter()
250            .map(|(k, v)| (k.to_string(), v.to_string()))
251            .collect();
252        move |k| m.get(k).cloned()
253    }
254
255    #[test]
256    fn parses_minimal_toml() {
257        let c = parse(
258            "host = \"tlbb\"\nnamespace = \"example-org\"\n[[repo]]\ndir = \"$CP_HOME/cp-conf\"\n",
259        )
260        .unwrap();
261        assert_eq!(c.host, "tlbb");
262        assert_eq!(c.namespace.as_deref(), Some("example-org"));
263        assert_eq!(c.repo.len(), 1);
264        assert_eq!(c.repo[0].name(), "cp-conf");
265        assert!(c.git_flags.is_empty() && c.pre_clone.0.is_empty());
266    }
267
268    #[test]
269    fn parses_full_toml_with_hooks_and_flags() {
270        let c = parse(
271            r#"
272host = "tlbb"
273namespace = "example-org"
274git-flags = ["-c", "http.x=y"]
275clone-flags = ["--filter=blob:none"]
276pre-clone = "echo global pre"
277post-clone = ["direnv allow ."]
278
279[[repo]]
280dir = "$D/cosp"
281depth = 1
282branch = "dev"
283clone-flags = ["--no-tags"]
284post-clone = ["mill compile", "echo done"]
285"#,
286        )
287        .unwrap();
288        assert_eq!(c.git_flags, ["-c", "http.x=y"]); // PRE
289        assert_eq!(c.clone_flags, ["--filter=blob:none"]); // POST global
290        assert_eq!(c.pre_clone.0, ["echo global pre"]); // string -> 1-elem list
291        assert_eq!(c.post_clone.0, ["direnv allow ."]);
292        let r = &c.repo[0];
293        assert_eq!(r.depth, Some(1));
294        assert_eq!(r.branch.as_deref(), Some("dev"));
295        assert_eq!(r.clone_flags, ["--no-tags"]);
296        assert_eq!(r.post_clone.0, ["mill compile", "echo done"]); // list kept
297    }
298
299    #[test]
300    fn name_overrides_basename_for_url() {
301        // clone the remote repo `cosp` into a differently-named local dir
302        let c = parse(
303            "host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\nname=\"cosp\"\n",
304        )
305        .unwrap();
306        assert_eq!(c.repo[0].name(), "cosp"); // URL uses `cosp`, dir is `my-cosp`
307                                              // default (no name) still uses basename
308        let d =
309            parse("host=\"h\"\nnamespace=\"o\"\n[[repo]]\ndir=\"$HOME/work/my-cosp\"\n").unwrap();
310        assert_eq!(d.repo[0].name(), "my-cosp");
311    }
312
313    #[test]
314    fn requires_host() {
315        // host is required by serde; missing it is a parse error.
316        assert!(parse("namespace = \"o\"\n").unwrap_err().contains("host"));
317    }
318
319    #[test]
320    fn namespace_optional_at_parse() {
321        // global namespace is now optional — host alone parses (validation is
322        // separate and per-repo).
323        let c = parse("host = \"h\"\n").unwrap();
324        assert_eq!(c.namespace, None);
325        assert!(c.validate().is_ok()); // no repos -> nothing to resolve
326    }
327
328    #[test]
329    fn per_repo_namespace_overrides_global() {
330        let c = parse(
331            "host=\"gh\"\nnamespace=\"glob\"\n[[repo]]\ndir=\"$H/a\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
332        )
333        .unwrap();
334        assert_eq!(c.namespace_for(&c.repo[0]), Some("glob")); // falls back to global
335        assert_eq!(c.namespace_for(&c.repo[1]), Some("bob")); // per-repo wins
336    }
337
338    #[test]
339    fn validate_ok_with_per_repo_namespace_no_global() {
340        // no global namespace, but each repo supplies its own -> valid
341        let c = parse(
342            "host=\"gh\"\n[[repo]]\ndir=\"$H/a\"\nnamespace=\"alice\"\n[[repo]]\ndir=\"$H/b\"\nnamespace=\"bob\"\n",
343        )
344        .unwrap();
345        assert!(c.validate().is_ok());
346        assert_eq!(c.namespace_for(&c.repo[0]), Some("alice"));
347    }
348
349    #[test]
350    fn validate_errors_when_no_namespace() {
351        // no global, and this repo has none -> validate names the offending dir
352        let c = parse("host=\"gh\"\n[[repo]]\ndir=\"$H/lonely\"\n").unwrap();
353        let err = c.validate().unwrap_err();
354        assert!(err.contains("$H/lonely"), "names the dir: {err}");
355        assert!(err.contains("namespace"));
356    }
357
358    #[test]
359    fn rejects_unknown_field() {
360        assert!(parse("host=\"h\"\nnamespace=\"o\"\nbogus=1\n").is_err());
361    }
362
363    #[test]
364    fn scp_url_parses_alias_form_only() {
365        assert_eq!(
366            scp_url_parts("tlbb:example-org/cosp.git"),
367            Some(("tlbb".into(), "example-org".into()))
368        );
369        assert_eq!(
370            scp_url_parts("ctl:grp/sub/repo.git"),
371            Some(("ctl".into(), "grp/sub".into()))
372        ); // gitlab subgroup
373        assert_eq!(scp_url_parts("git@github.com:org/repo.git"), None); // user@ form -> skip
374        assert_eq!(scp_url_parts("https://github.com/org/repo.git"), None); // https -> skip
375        assert_eq!(scp_url_parts("tlbb:noslash"), None);
376    }
377
378    #[test]
379    fn rejects_solo_field() {
380        // `solo` is no longer a conf key — it's set manually via `git config
381        // gkit.solo`. A leftover `solo =` must be a hard parse error.
382        assert!(parse("host=\"h\"\nnamespace=\"o\"\nsolo=true\n").is_err());
383    }
384
385    #[test]
386    fn template_fills_or_placeholders() {
387        let filled = template(Some("tlbb"), Some("example-org"));
388        assert!(filled.contains("host      = \"tlbb\""));
389        assert!(filled.contains("namespace = \"example-org\""));
390        assert!(filled.contains("[[repo]]"));
391        assert!(filled.contains(r#"post-clone = ["git config gkit.baseBranch main"]"#));
392        let blank = template(None, None);
393        assert!(blank.contains("<ssh-host-alias>") && blank.contains("<namespace>"));
394        // the template must itself be valid TOML that parses
395        assert!(parse(&filled).is_ok());
396    }
397
398    #[test]
399    fn expands_home_and_vars() {
400        let get = env(&[("HOME", "/h"), ("CP_HOME", "/c"), ("X", "/x")]);
401        assert_eq!(expand_path("~/foo", &get), "/h/foo");
402        assert_eq!(expand_path("$CP_HOME/cp-conf", &get), "/c/cp-conf");
403        assert_eq!(expand_path("${X}/b", &get), "/x/b");
404        assert_eq!(expand_path("/abs", &get), "/abs");
405        assert_eq!(expand_path("$UNSET/y", &get), "/y");
406    }
407}