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}