polykit_core/
config.rs

1//! TOML configuration parsing for package definitions.
2
3use rustc_hash::FxHashMap;
4use serde::{Deserialize, Deserializer, Serialize};
5
6use crate::package::{Language, Task};
7use crate::remote_cache::RemoteCacheConfig;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(untagged)]
11pub enum TaskValue {
12    Simple(String),
13    Complex {
14        command: String,
15        #[serde(default)]
16        depends_on: Vec<String>,
17    },
18}
19
20/// Package configuration as defined in `polykit.toml`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Config {
23    pub name: String,
24    pub language: String,
25    pub public: bool,
26    #[serde(default)]
27    pub deps: Deps,
28    #[serde(deserialize_with = "deserialize_tasks")]
29    #[serde(default)]
30    pub tasks: FxHashMap<String, TaskValue>,
31}
32
33pub(crate) fn deserialize_tasks<'de, D>(
34    deserializer: D,
35) -> Result<FxHashMap<String, TaskValue>, D::Error>
36where
37    D: Deserializer<'de>,
38{
39    let map: FxHashMap<String, toml::Value> = FxHashMap::deserialize(deserializer)?;
40    let mut result = FxHashMap::default();
41    let mut dotted_deps: FxHashMap<String, Vec<String>> = FxHashMap::default();
42
43    // First pass: parse regular tasks and collect dotted-key dependencies
44    for (key, value) in map {
45        // Check if this is a dotted-key dependency (e.g., "test.depends_on")
46        if let Some((task_name, dep_key)) = key.split_once('.') {
47            if dep_key == "depends_on" {
48                if let toml::Value::Array(arr) = value {
49                    let deps: Vec<String> = arr
50                        .iter()
51                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
52                        .collect();
53                    dotted_deps.insert(task_name.to_string(), deps);
54                } else {
55                    return Err(serde::de::Error::custom(format!(
56                        "Task dependency '{}' must be an array",
57                        key
58                    )));
59                }
60                continue;
61            }
62        }
63
64        match value {
65            toml::Value::String(s) => {
66                result.insert(key, TaskValue::Simple(s));
67            }
68            toml::Value::Table(t) => {
69                let command = t
70                    .get("command")
71                    .and_then(|v| v.as_str())
72                    .ok_or_else(|| {
73                        serde::de::Error::custom("Task table must have 'command' field")
74                    })?
75                    .to_string();
76                let depends_on = t
77                    .get("depends_on")
78                    .and_then(|v| v.as_array())
79                    .map(|arr| {
80                        arr.iter()
81                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
82                            .collect()
83                    })
84                    .unwrap_or_default();
85                result.insert(
86                    key,
87                    TaskValue::Complex {
88                        command,
89                        depends_on,
90                    },
91                );
92            }
93            _ => {
94                return Err(serde::de::Error::custom(
95                    "Task value must be a string or a table",
96                ));
97            }
98        }
99    }
100
101    // Second pass: merge dotted-key dependencies into existing tasks
102    for (task_name, deps) in dotted_deps {
103        let task_value = result.remove(&task_name).ok_or_else(|| {
104            serde::de::Error::custom(format!(
105                "Task '{}' referenced in dotted-key dependency does not exist",
106                task_name
107            ))
108        })?;
109
110        match task_value {
111            TaskValue::Simple(command) => {
112                result.insert(
113                    task_name,
114                    TaskValue::Complex {
115                        command,
116                        depends_on: deps,
117                    },
118                );
119            }
120            TaskValue::Complex { command, .. } => {
121                // Replace dependencies (dotted-key takes precedence)
122                result.insert(
123                    task_name,
124                    TaskValue::Complex {
125                        command,
126                        depends_on: deps,
127                    },
128                );
129            }
130        }
131    }
132
133    Ok(result)
134}
135
136pub(crate) fn parse_tasks_from_toml_map(
137    map: &toml::map::Map<String, toml::Value>,
138) -> FxHashMap<String, TaskValue> {
139    let mut result = FxHashMap::default();
140    let mut dotted_deps: FxHashMap<String, Vec<String>> = FxHashMap::default();
141
142    for (key, value) in map {
143        if let Some((task_name, dep_key)) = key.split_once('.') {
144            if dep_key == "depends_on" {
145                if let toml::Value::Array(arr) = value {
146                    let deps: Vec<String> = arr
147                        .iter()
148                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
149                        .collect();
150                    dotted_deps.insert(task_name.to_string(), deps);
151                }
152                continue;
153            }
154        }
155
156        match value {
157            toml::Value::String(s) => {
158                result.insert(key.clone(), TaskValue::Simple(s.clone()));
159            }
160            toml::Value::Table(t) => {
161                if let Some(command_val) = t.get("command").and_then(|v| v.as_str()) {
162                    let depends_on = t
163                        .get("depends_on")
164                        .and_then(|v| v.as_array())
165                        .map(|arr| {
166                            arr.iter()
167                                .filter_map(|v| v.as_str().map(|s| s.to_string()))
168                                .collect()
169                        })
170                        .unwrap_or_default();
171                    result.insert(
172                        key.clone(),
173                        TaskValue::Complex {
174                            command: command_val.to_string(),
175                            depends_on,
176                        },
177                    );
178                }
179            }
180            _ => {}
181        }
182    }
183
184    for (task_name, deps) in dotted_deps {
185        if let Some(task_value) = result.remove(&task_name) {
186            match task_value {
187                TaskValue::Simple(command) => {
188                    result.insert(
189                        task_name,
190                        TaskValue::Complex {
191                            command,
192                            depends_on: deps,
193                        },
194                    );
195                }
196                TaskValue::Complex { command, .. } => {
197                    result.insert(
198                        task_name,
199                        TaskValue::Complex {
200                            command,
201                            depends_on: deps,
202                        },
203                    );
204                }
205            }
206        }
207    }
208
209    result
210}
211
212/// Package dependencies configuration.
213#[derive(Debug, Clone, Default, Serialize, Deserialize)]
214pub struct Deps {
215    /// List of internal package dependencies.
216    #[serde(default)]
217    pub internal: Vec<String>,
218}
219
220/// Workspace-level configuration.
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub struct WorkspaceConfig {
223    /// Cache directory path.
224    pub cache_dir: Option<String>,
225    /// Default number of parallel jobs.
226    pub default_parallel: Option<usize>,
227    /// Path to the workspace config file (for resolving relative paths).
228    #[serde(skip)]
229    pub workspace_config_path: Option<std::path::PathBuf>,
230    /// Workspace-level tasks that apply to all packages.
231    #[serde(default, deserialize_with = "deserialize_tasks")]
232    pub tasks: FxHashMap<String, TaskValue>,
233    /// Remote cache configuration.
234    #[serde(default)]
235    pub remote_cache: Option<RemoteCacheConfig>,
236}
237
238impl Config {
239    pub fn parse_language(&self) -> Result<Language, crate::Error> {
240        Language::from_str(&self.language).ok_or_else(|| crate::Error::InvalidLanguage {
241            lang: self.language.clone(),
242        })
243    }
244
245    pub fn to_tasks(&self) -> Vec<Task> {
246        self.tasks
247            .iter()
248            .map(|(name, task_value)| match task_value {
249                TaskValue::Simple(command) => Task {
250                    name: name.clone(),
251                    command: command.clone(),
252                    depends_on: Vec::new(),
253                },
254                TaskValue::Complex {
255                    command,
256                    depends_on,
257                } => Task {
258                    name: name.clone(),
259                    command: command.clone(),
260                    depends_on: depends_on.clone(),
261                },
262            })
263            .collect()
264    }
265}
266
267impl WorkspaceConfig {
268    pub fn to_tasks(&self) -> Vec<Task> {
269        self.tasks
270            .iter()
271            .map(|(name, task_value)| match task_value {
272                TaskValue::Simple(command) => Task {
273                    name: name.clone(),
274                    command: command.clone(),
275                    depends_on: Vec::new(),
276                },
277                TaskValue::Complex {
278                    command,
279                    depends_on,
280                } => Task {
281                    name: name.clone(),
282                    command: command.clone(),
283                    depends_on: depends_on.clone(),
284                },
285            })
286            .collect()
287    }
288}