Skip to main content

ferrflow/validate/
source.rs

1use anyhow::{Context, Result};
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4
5use crate::config::Config;
6use crate::error_code::{self, ErrorCodeExt};
7
8#[cfg(feature = "cli")]
9fn encode_path(s: &str) -> String {
10    encode_with_safe(s, |b| matches!(b, b'/'))
11}
12
13#[cfg(feature = "cli")]
14fn encode_query_value(s: &str) -> String {
15    encode_with_safe(s, |_| false)
16}
17
18#[cfg(feature = "cli")]
19fn encode_with_safe(s: &str, extra_safe: impl Fn(u8) -> bool) -> String {
20    let mut out = String::with_capacity(s.len());
21    for b in s.bytes() {
22        let unreserved =
23            b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') || extra_safe(b);
24        if unreserved {
25            out.push(b as char);
26        } else {
27            out.push_str(&format!("%{b:02X}"));
28        }
29    }
30    out
31}
32
33pub trait FileSource {
34    fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>>;
35    fn path_exists(&self, path: &str) -> Result<bool>;
36}
37
38pub struct LocalSource {
39    pub root: PathBuf,
40}
41
42impl FileSource for LocalSource {
43    fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>> {
44        let full = self.root.join(path);
45        match std::fs::read(&full) {
46            Ok(content) => Ok(Some(content)),
47            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
48            Err(e) => Err(e.into()),
49        }
50    }
51
52    fn path_exists(&self, path: &str) -> Result<bool> {
53        Ok(self.root.join(path).exists())
54    }
55}
56
57#[cfg_attr(feature = "cli", allow(dead_code))]
58pub struct MemorySource {
59    files: BTreeMap<String, Vec<u8>>,
60}
61
62#[cfg_attr(feature = "cli", allow(dead_code))]
63impl MemorySource {
64    pub fn new(files: BTreeMap<String, Vec<u8>>) -> Self {
65        Self { files }
66    }
67}
68
69impl FileSource for MemorySource {
70    fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>> {
71        Ok(self.files.get(path).cloned())
72    }
73
74    fn path_exists(&self, path: &str) -> Result<bool> {
75        if self.files.contains_key(path) {
76            return Ok(true);
77        }
78        let prefix = format!("{}/", path.trim_end_matches('/'));
79        Ok(self.files.keys().any(|key| key.starts_with(&prefix)))
80    }
81}
82
83#[cfg(feature = "cli")]
84#[derive(Debug, PartialEq)]
85pub enum RemoteProvider {
86    GitHub,
87    GitLab,
88}
89
90#[cfg(feature = "cli")]
91pub fn parse_repo_spec(spec: &str) -> Result<(RemoteProvider, String, String)> {
92    let spec = spec
93        .trim_start_matches("https://")
94        .trim_start_matches("http://");
95    let parts: Vec<&str> = spec.split('/').collect();
96    match parts.len() {
97        2 => Ok((
98            RemoteProvider::GitHub,
99            parts[0].to_string(),
100            parts[1].to_string(),
101        )),
102        3 => {
103            let host = parts[0].to_lowercase();
104            let provider = if host.contains("gitlab") {
105                RemoteProvider::GitLab
106            } else {
107                RemoteProvider::GitHub
108            };
109            Ok((provider, parts[1].to_string(), parts[2].to_string()))
110        }
111        _ => Err(anyhow::anyhow!(
112            "Invalid repo spec: {spec}. Expected owner/repo or host/owner/repo"
113        ))
114        .error_code(error_code::VALIDATE_INVALID_REPO_SPEC)?,
115    }
116}
117
118#[cfg(feature = "cli")]
119pub struct GitHubSource {
120    pub owner: String,
121    pub repo: String,
122    pub git_ref: Option<String>,
123    pub token: Option<String>,
124}
125
126#[cfg(feature = "cli")]
127impl FileSource for GitHubSource {
128    fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>> {
129        let mut url = format!(
130            "https://api.github.com/repos/{}/{}/contents/{}",
131            encode_path(&self.owner),
132            encode_path(&self.repo),
133            encode_path(path)
134        );
135        if let Some(ref r) = self.git_ref {
136            url.push_str(&format!("?ref={}", encode_query_value(r)));
137        }
138        let mut req = ureq::get(&url).header("Accept", "application/vnd.github.v3.raw");
139        if let Some(ref token) = self.token {
140            req = req.header("Authorization", &format!("Bearer {token}"));
141        }
142        req = req.header("User-Agent", "ferrflow");
143        match req.call() {
144            Ok(mut resp) => {
145                let body = resp.body_mut().read_to_vec()?;
146                Ok(Some(body))
147            }
148            Err(ureq::Error::StatusCode(404)) => Ok(None),
149            Err(e) => Err(anyhow::anyhow!("GitHub API error for {path}: {e}"))
150                .error_code(error_code::VALIDATE_GITHUB_API),
151        }
152    }
153
154    fn path_exists(&self, path: &str) -> Result<bool> {
155        Ok(self.read_file(path)?.is_some())
156    }
157}
158
159#[cfg(feature = "cli")]
160pub struct GitLabSource {
161    pub owner: String,
162    pub repo: String,
163    pub git_ref: Option<String>,
164    pub token: Option<String>,
165}
166
167#[cfg(feature = "cli")]
168impl FileSource for GitLabSource {
169    fn read_file(&self, path: &str) -> Result<Option<Vec<u8>>> {
170        let project_id = format!("{}/{}", self.owner, self.repo);
171        let encoded_project = encode_query_value(&project_id);
172        let encoded_path = encode_query_value(path);
173        let mut url = format!(
174            "https://gitlab.com/api/v4/projects/{encoded_project}/repository/files/{encoded_path}/raw"
175        );
176        if let Some(ref r) = self.git_ref {
177            url.push_str(&format!("?ref={}", encode_query_value(r)));
178        } else {
179            url.push_str("?ref=main");
180        }
181        let mut req = ureq::get(&url);
182        if let Some(ref token) = self.token {
183            req = req.header("PRIVATE-TOKEN", token);
184        }
185        req = req.header("User-Agent", "ferrflow");
186        match req.call() {
187            Ok(mut resp) => {
188                let body = resp.body_mut().read_to_vec()?;
189                Ok(Some(body))
190            }
191            Err(ureq::Error::StatusCode(404)) => Ok(None),
192            Err(e) => Err(anyhow::anyhow!("GitLab API error for {path}: {e}"))
193                .error_code(error_code::VALIDATE_GITLAB_API),
194        }
195    }
196
197    fn path_exists(&self, path: &str) -> Result<bool> {
198        Ok(self.read_file(path)?.is_some())
199    }
200}
201
202const CONFIG_FILENAMES: &[&str] = &[
203    "ferrflow.json",
204    "ferrflow.json5",
205    "ferrflow.toml",
206    ".ferrflow",
207];
208
209pub(super) fn parse_config_content(content: &[u8], filename: &str) -> Result<Config> {
210    let text = std::str::from_utf8(content)
211        .with_context(|| format!("Invalid UTF-8 in {filename}"))
212        .error_code(error_code::VALIDATE_INVALID_UTF8)?;
213    match filename {
214        f if f.ends_with(".toml") => toml_edit::de::from_str(text)
215            .with_context(|| format!("Failed to parse {filename}"))
216            .error_code(error_code::VALIDATE_PARSE_FAILED),
217        f if f.ends_with(".json5") => json5::from_str(text)
218            .with_context(|| format!("Failed to parse {filename}"))
219            .error_code(error_code::VALIDATE_PARSE_FAILED),
220        _ => serde_json::from_str(text)
221            .with_context(|| format!("Failed to parse {filename}"))
222            .error_code(error_code::VALIDATE_PARSE_FAILED),
223    }
224}
225
226pub fn load_config_from_source(
227    source: &dyn FileSource,
228    explicit_path: Option<&str>,
229) -> Result<(Config, String)> {
230    if let Some(path) = explicit_path {
231        let content = source
232            .read_file(path)?
233            .ok_or_else(|| anyhow::anyhow!("Config file not found: {path}"))
234            .error_code(error_code::VALIDATE_FILE_NOT_FOUND)?;
235        let config = parse_config_content(&content, path)?;
236        return Ok((config, path.to_string()));
237    }
238    for filename in CONFIG_FILENAMES {
239        if let Some(content) = source.read_file(filename)? {
240            let config = parse_config_content(&content, filename)?;
241            return Ok((config, filename.to_string()));
242        }
243    }
244    Err(anyhow::anyhow!(
245        "No FerrFlow configuration file found. Looked for: {}",
246        CONFIG_FILENAMES.join(", ")
247    ))
248    .error_code(error_code::VALIDATE_NO_CONFIG)?
249}
250
251#[cfg(all(test, feature = "cli"))]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn encode_path_preserves_slashes_and_unreserved() {
257        assert_eq!(encode_path("src/foo.rs"), "src/foo.rs");
258        assert_eq!(encode_path("a-_.~b"), "a-_.~b");
259    }
260
261    #[test]
262    fn encode_path_percent_encodes_specials() {
263        assert_eq!(encode_path("a b"), "a%20b");
264        assert_eq!(encode_path("a?b"), "a%3Fb");
265        assert_eq!(encode_path("a#b"), "a%23b");
266        assert_eq!(encode_path("a&b"), "a%26b");
267    }
268
269    #[test]
270    fn encode_query_value_encodes_slash_too() {
271        assert_eq!(encode_query_value("feat/x"), "feat%2Fx");
272        assert_eq!(encode_query_value("a=b&c"), "a%3Db%26c");
273        assert_eq!(encode_query_value("a?b#c"), "a%3Fb%23c");
274    }
275}