1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11#[serde(rename_all = "lowercase")]
12pub enum Language {
13 Rust,
15 TypeScript,
17 JavaScript,
19 Python,
21 Anchor,
23 Go,
25}
26
27impl std::fmt::Display for Language {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::Rust => write!(f, "rust"),
31 Self::TypeScript => write!(f, "typescript"),
32 Self::JavaScript => write!(f, "javascript"),
33 Self::Python => write!(f, "python"),
34 Self::Anchor => write!(f, "anchor"),
35 Self::Go => write!(f, "go"),
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Project {
43 pub name: String,
45 pub path: PathBuf,
47 pub language: Language,
49 pub manifest_path: PathBuf,
51 pub context_summary: Option<String>,
53 pub public_api_files: Vec<PathBuf>,
55 pub internal_files: Vec<PathBuf>,
57 pub content_hash: [u8; 32],
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum DepKind {
65 Source,
67 Build,
69 Dev,
71 Cpi,
73}
74
75impl std::fmt::Display for DepKind {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::Source => write!(f, "source"),
79 Self::Build => write!(f, "build"),
80 Self::Dev => write!(f, "dev"),
81 Self::Cpi => write!(f, "cpi"),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Dependency {
89 pub name: String,
91 pub version_req: String,
93 pub kind: DepKind,
95 pub is_workspace: bool,
97 pub resolved_path: Option<PathBuf>,
99}
100
101#[derive(Debug, thiserror::Error, miette::Diagnostic)]
103pub enum KdoError {
104 #[error("workspace manifest not found at {0}")]
106 ManifestNotFound(PathBuf),
107
108 #[error("failed to parse {path}: {source}")]
110 ParseError {
111 path: PathBuf,
113 source: anyhow::Error,
115 },
116
117 #[error("project not found: {0}")]
119 ProjectNotFound(String),
120
121 #[error("circular dependency detected: {0}")]
123 #[diagnostic(help("break the cycle by extracting shared code into a separate crate"))]
124 CircularDependency(String),
125
126 #[error(transparent)]
128 Io(#[from] std::io::Error),
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct WorkspaceConfig {
137 pub workspace: WorkspaceMeta,
139
140 #[serde(default)]
142 pub tasks: std::collections::BTreeMap<String, TaskSpec>,
143
144 #[serde(default)]
146 pub env: std::collections::BTreeMap<String, String>,
147
148 #[serde(default)]
151 pub env_files: Vec<String>,
152
153 #[serde(default)]
155 pub aliases: std::collections::BTreeMap<String, String>,
156
157 #[serde(default)]
159 pub projects: std::collections::BTreeMap<String, ProjectConfig>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct WorkspaceMeta {
165 #[serde(default)]
167 pub name: String,
168
169 #[serde(default, rename = "projects")]
172 pub project_globs: Vec<String>,
173
174 #[serde(default)]
176 pub exclude: Vec<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(untagged)]
183pub enum TaskSpec {
184 Command(String),
186 Full(TaskDef),
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, Default)]
192pub struct TaskDef {
193 #[serde(default)]
195 pub command: Option<String>,
196
197 #[serde(default)]
203 pub depends_on: Vec<String>,
204
205 #[serde(default)]
207 pub inputs: Vec<String>,
208
209 #[serde(default)]
211 pub outputs: Vec<String>,
212
213 #[serde(default = "default_true")]
215 pub cache: bool,
216
217 #[serde(default)]
219 pub persistent: bool,
220
221 #[serde(default)]
223 pub env: std::collections::BTreeMap<String, String>,
224}
225
226fn default_true() -> bool {
227 true
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, Default)]
232pub struct ProjectConfig {
233 #[serde(default)]
235 pub tasks: std::collections::BTreeMap<String, TaskSpec>,
236
237 #[serde(default)]
239 pub env: std::collections::BTreeMap<String, String>,
240}
241
242impl TaskSpec {
243 pub fn command(&self) -> Option<&str> {
245 match self {
246 Self::Command(c) => Some(c.as_str()),
247 Self::Full(def) => def.command.as_deref(),
248 }
249 }
250
251 pub fn depends_on(&self) -> &[String] {
253 match self {
254 Self::Command(_) => &[],
255 Self::Full(def) => &def.depends_on,
256 }
257 }
258
259 pub fn env(&self) -> &std::collections::BTreeMap<String, String> {
261 static EMPTY: std::sync::OnceLock<std::collections::BTreeMap<String, String>> =
262 std::sync::OnceLock::new();
263 match self {
264 Self::Command(_) => EMPTY.get_or_init(std::collections::BTreeMap::new),
265 Self::Full(def) => &def.env,
266 }
267 }
268
269 pub fn persistent(&self) -> bool {
271 matches!(self, Self::Full(def) if def.persistent)
272 }
273}
274
275impl WorkspaceConfig {
276 pub fn load(path: &std::path::Path) -> Result<Self, KdoError> {
278 let content = std::fs::read_to_string(path)?;
279 toml::from_str(&content).map_err(|e| KdoError::ParseError {
280 path: path.to_path_buf(),
281 source: e.into(),
282 })
283 }
284
285 pub fn save(&self, path: &std::path::Path) -> Result<(), KdoError> {
287 let content = toml::to_string_pretty(self).map_err(|e| KdoError::ParseError {
288 path: path.to_path_buf(),
289 source: e.into(),
290 })?;
291 std::fs::write(path, content)?;
292 Ok(())
293 }
294
295 pub fn resolve_alias<'a>(&'a self, name: &'a str) -> &'a str {
297 self.aliases.get(name).map(String::as_str).unwrap_or(name)
298 }
299}
300
301pub fn estimate_tokens(s: &str) -> usize {
310 s.len() / 4
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn test_estimate_tokens() {
319 assert_eq!(estimate_tokens(""), 0);
320 assert_eq!(estimate_tokens("abcd"), 1);
321 assert_eq!(estimate_tokens("ab"), 0);
322 assert_eq!(estimate_tokens("hello world!"), 3);
323 }
324
325 #[test]
326 fn test_language_display() {
327 assert_eq!(Language::Rust.to_string(), "rust");
328 assert_eq!(Language::Anchor.to_string(), "anchor");
329 }
330
331 #[test]
332 fn test_language_serde_roundtrip() {
333 let lang = Language::TypeScript;
334 let json = serde_json::to_string(&lang).unwrap();
335 assert_eq!(json, "\"typescript\"");
336 let back: Language = serde_json::from_str(&json).unwrap();
337 assert_eq!(back, lang);
338 }
339
340 #[test]
341 fn test_dep_kind_display() {
342 assert_eq!(DepKind::Cpi.to_string(), "cpi");
343 assert_eq!(DepKind::Source.to_string(), "source");
344 }
345}