Skip to main content

outpost_core/
metadata.rs

1use std::path::{Path, PathBuf};
2
3use crate::{GitInvoker, OutpostError, OutpostResult, RemoteName};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct RawMetadata {
7    pub managed: Option<bool>,
8    pub source_repo: Option<PathBuf>,
9    pub remote_name: Option<RemoteName>,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Metadata {
14    pub source_repo: PathBuf,
15    pub remote_name: RemoteName,
16}
17
18impl RawMetadata {
19    pub fn read(git: &GitInvoker) -> OutpostResult<Self> {
20        let managed = match read_optional_config(git, "outpost.managed")? {
21            Some(value) => {
22                Some(
23                    parse_git_bool(&value).ok_or_else(|| OutpostError::BadMetadata {
24                        outpost: git.cwd().to_path_buf(),
25                        reason: format!("invalid outpost.managed value: {value}"),
26                    })?,
27                )
28            }
29            None => None,
30        };
31        let source_repo = read_optional_config(git, "outpost.sourceRepo")?.map(PathBuf::from);
32        let remote_name = read_optional_config(git, "outpost.remoteName")?
33            .map(RemoteName::parse)
34            .transpose()?;
35
36        Ok(Self {
37            managed,
38            source_repo,
39            remote_name,
40        })
41    }
42}
43
44impl Metadata {
45    pub fn from_raw(outpost: &Path, raw: RawMetadata) -> OutpostResult<Self> {
46        if raw.managed != Some(true) {
47            return Err(OutpostError::NotAnOutpost(outpost.to_path_buf()));
48        }
49
50        let source_repo = raw.source_repo.ok_or_else(|| OutpostError::BadMetadata {
51            outpost: outpost.to_path_buf(),
52            reason: "missing outpost.sourceRepo".to_owned(),
53        })?;
54        let remote_name = raw.remote_name.ok_or_else(|| OutpostError::BadMetadata {
55            outpost: outpost.to_path_buf(),
56            reason: "missing outpost.remoteName".to_owned(),
57        })?;
58
59        Ok(Self {
60            source_repo,
61            remote_name,
62        })
63    }
64
65    pub fn write(&self, git: &GitInvoker) -> OutpostResult<()> {
66        let source_repo =
67            std::fs::canonicalize(&self.source_repo).map_err(|source| OutpostError::IoAt {
68                path: self.source_repo.clone(),
69                source,
70            })?;
71        let source_repo = source_repo.to_string_lossy().into_owned();
72
73        git.run_check(["config", "--local", "outpost.managed", "true"])?;
74        git.run_check(["config", "--local", "outpost.sourceRepo", &source_repo])?;
75        git.run_check([
76            "config",
77            "--local",
78            "outpost.remoteName",
79            self.remote_name.as_str(),
80        ])?;
81        Ok(())
82    }
83}
84
85fn read_optional_config(git: &GitInvoker, key: &str) -> OutpostResult<Option<String>> {
86    if git.run_status(["config", "--local", "--get", key])? {
87        git.run_capture(["config", "--local", "--get", key])
88            .map(Some)
89    } else {
90        Ok(None)
91    }
92}
93
94fn parse_git_bool(value: &str) -> Option<bool> {
95    match value.trim().to_ascii_lowercase().as_str() {
96        "true" | "yes" | "on" | "1" => Some(true),
97        "false" | "no" | "off" | "0" => Some(false),
98        _ => None,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::collections::BTreeMap;
105    use std::ffi::OsString;
106    use std::fs;
107    use std::path::Path;
108
109    use super::*;
110
111    #[test]
112    fn metadata_write_sets_local_outpost_config_keys() {
113        let temp = tempfile::tempdir().expect("tempdir");
114        let outpost = temp.path().join("outpost");
115        let source = temp.path().join("source");
116        init_repo(&outpost);
117        init_repo(&source);
118
119        let metadata = Metadata {
120            source_repo: source.clone(),
121            remote_name: RemoteName::parse("local").expect("remote parses"),
122        };
123        let git = GitInvoker::at(&outpost);
124
125        metadata.write(&git).expect("metadata writes");
126
127        assert_eq!(
128            git.run_capture(["config", "--local", "--get", "outpost.managed"])
129                .expect("managed key"),
130            "true"
131        );
132        assert_eq!(
133            git.run_capture(["config", "--local", "--get", "outpost.sourceRepo"])
134                .expect("source key"),
135            fs::canonicalize(&source)
136                .expect("canonical source")
137                .to_string_lossy()
138        );
139        assert_eq!(
140            git.run_capture(["config", "--local", "--get", "outpost.remoteName"])
141                .expect("remote key"),
142            "local"
143        );
144        assert!(
145            !git.run_status(["config", "--local", "--get", "outpost.id"])
146                .expect("id key absent")
147        );
148    }
149
150    #[test]
151    fn raw_metadata_on_non_managed_repo_promotes_to_not_an_outpost() {
152        let temp = tempfile::tempdir().expect("tempdir");
153        init_repo(temp.path());
154        let raw = RawMetadata::read(&GitInvoker::at(temp.path())).expect("read raw metadata");
155
156        assert_eq!(raw.managed, None);
157        assert!(matches!(
158            Metadata::from_raw(temp.path(), raw),
159            Err(OutpostError::NotAnOutpost(path)) if path == temp.path()
160        ));
161
162        let raw_false = RawMetadata {
163            managed: Some(false),
164            source_repo: None,
165            remote_name: None,
166        };
167        assert!(matches!(
168            Metadata::from_raw(temp.path(), raw_false),
169            Err(OutpostError::NotAnOutpost(path)) if path == temp.path()
170        ));
171    }
172
173    #[test]
174    fn raw_metadata_read_ignores_global_outpost_managed_config() {
175        let temp = tempfile::tempdir().expect("tempdir");
176        let repo = temp.path().join("repo");
177        let global = temp.path().join("global.gitconfig");
178        init_repo(&repo);
179        fs::write(&global, "[outpost]\n\tmanaged = true\n").expect("write global config");
180
181        let env = BTreeMap::from([(
182            OsString::from("GIT_CONFIG_GLOBAL"),
183            global.as_os_str().to_os_string(),
184        )]);
185        let git = env.iter().fold(GitInvoker::at(&repo), |git, (key, val)| {
186            git.with_env(key.clone(), val.clone())
187        });
188
189        let raw = RawMetadata::read(&git).expect("read raw metadata");
190        assert_eq!(raw.managed, None);
191    }
192
193    fn init_repo(path: &Path) {
194        fs::create_dir_all(path).expect("create repo dir");
195        GitInvoker::at(path)
196            .run_check(["init", "--initial-branch=main"])
197            .expect("init repo");
198    }
199}