Skip to main content

kdo_core/
lib.rs

1//! Core types and error definitions for the kdo workspace manager.
2//!
3//! This crate provides the foundational data structures used across all kdo crates:
4//! [`Project`], [`Dependency`], [`Language`], and the unified [`KdoError`] type.
5
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Programming language / framework detected for a project.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
11#[serde(rename_all = "lowercase")]
12pub enum Language {
13    /// Pure Rust (Cargo)
14    Rust,
15    /// TypeScript (package.json with TS)
16    TypeScript,
17    /// JavaScript (package.json)
18    JavaScript,
19    /// Python (pyproject.toml / setup.py)
20    Python,
21    /// Rust + Anchor framework (Solana)
22    Anchor,
23    /// Go (go.mod)
24    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/// A discovered project within the workspace.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Project {
43    /// Human-readable project name (from manifest).
44    pub name: String,
45    /// Root directory of the project, relative to workspace root.
46    pub path: PathBuf,
47    /// Detected language / framework.
48    pub language: Language,
49    /// Path to the primary manifest file (Cargo.toml, package.json, etc.).
50    pub manifest_path: PathBuf,
51    /// One-line summary extracted from manifest or CONTEXT.md.
52    pub context_summary: Option<String>,
53    /// Files that constitute the public API surface.
54    pub public_api_files: Vec<PathBuf>,
55    /// Internal implementation files.
56    pub internal_files: Vec<PathBuf>,
57    /// Blake3 content hash of all project files (deterministic).
58    pub content_hash: [u8; 32],
59}
60
61/// Dependency relationship kind.
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "lowercase")]
64pub enum DepKind {
65    /// Normal source dependency.
66    Source,
67    /// Build-time dependency (build.rs, scripts).
68    Build,
69    /// Development / test dependency.
70    Dev,
71    /// Solana Cross-Program Invocation dependency.
72    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/// A single dependency edge between projects.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Dependency {
89    /// Dependency name (as declared in manifest).
90    pub name: String,
91    /// Version requirement string (e.g., "^1.0", "workspace:*").
92    pub version_req: String,
93    /// Kind of dependency.
94    pub kind: DepKind,
95    /// Whether this dependency uses workspace inheritance.
96    pub is_workspace: bool,
97    /// Resolved path to the dependency within the workspace (if local).
98    pub resolved_path: Option<PathBuf>,
99}
100
101/// Unified error type for all kdo operations.
102#[derive(Debug, thiserror::Error, miette::Diagnostic)]
103pub enum KdoError {
104    /// Workspace manifest not found at the expected path.
105    #[error("workspace manifest not found at {0}")]
106    ManifestNotFound(PathBuf),
107
108    /// Failed to parse a manifest or source file.
109    #[error("failed to parse {path}: {source}")]
110    ParseError {
111        /// Path to the file that failed to parse.
112        path: PathBuf,
113        /// Underlying parse error.
114        source: anyhow::Error,
115    },
116
117    /// Referenced project does not exist in the workspace.
118    #[error("project not found: {0}")]
119    ProjectNotFound(String),
120
121    /// Circular dependency detected in the workspace graph.
122    #[error("circular dependency detected: {0}")]
123    #[diagnostic(help("break the cycle by extracting shared code into a separate crate"))]
124    CircularDependency(String),
125
126    /// I/O error.
127    #[error(transparent)]
128    Io(#[from] std::io::Error),
129}
130
131/// Workspace configuration parsed from `kdo.toml`.
132///
133/// Supports a rich task pipeline model with dependencies, env, aliases, and per-project
134/// overrides. Simple string tasks (`build = "cargo build"`) continue to work.
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct WorkspaceConfig {
137    /// Workspace metadata.
138    pub workspace: WorkspaceMeta,
139
140    /// Named tasks. Values can be a bare command string or a rich `[tasks.<name>]` table.
141    #[serde(default)]
142    pub tasks: std::collections::BTreeMap<String, TaskSpec>,
143
144    /// Workspace-wide environment variables, applied to every task invocation.
145    #[serde(default)]
146    pub env: std::collections::BTreeMap<String, String>,
147
148    /// Paths to `.env`-style files loaded before task execution.
149    /// Keys already in [`Self::env`] take precedence.
150    #[serde(default)]
151    pub env_files: Vec<String>,
152
153    /// Short aliases for tasks, e.g. `b = "build"` so `kdo run b` resolves to `build`.
154    #[serde(default)]
155    pub aliases: std::collections::BTreeMap<String, String>,
156
157    /// Per-project overrides keyed by project name.
158    #[serde(default)]
159    pub projects: std::collections::BTreeMap<String, ProjectConfig>,
160}
161
162/// Workspace metadata section of `kdo.toml`.
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct WorkspaceMeta {
165    /// Workspace name.
166    #[serde(default)]
167    pub name: String,
168
169    /// Explicit glob patterns for project discovery. When set, only these paths
170    /// are scanned for manifests. Empty = scan everything (default behavior).
171    #[serde(default, rename = "projects")]
172    pub project_globs: Vec<String>,
173
174    /// Paths to exclude from project discovery.
175    #[serde(default)]
176    pub exclude: Vec<String>,
177}
178
179/// A single task definition. Can be declared either as a bare command string
180/// (`build = "cargo build"`) or as a full spec (`[tasks.build] command = "..."`).
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(untagged)]
183pub enum TaskSpec {
184    /// Bare command form: `build = "cargo build"`.
185    Command(String),
186    /// Full spec form with dependencies, inputs, env, and caching hints.
187    Full(TaskDef),
188}
189
190/// Full task definition with pipeline semantics.
191#[derive(Debug, Clone, Serialize, Deserialize, Default)]
192pub struct TaskDef {
193    /// Shell command to execute. Optional when the task is purely composite (only `depends_on`).
194    #[serde(default)]
195    pub command: Option<String>,
196
197    /// Task dependencies.
198    ///
199    /// - `"build"` — run this project's `build` task first (same project).
200    /// - `"^build"` — run `build` in every upstream dependency project first (topological).
201    /// - `"//lint"` — run the workspace-level `lint` task first.
202    #[serde(default)]
203    pub depends_on: Vec<String>,
204
205    /// Input glob patterns. Used for cache key computation (future: content-addressable cache).
206    #[serde(default)]
207    pub inputs: Vec<String>,
208
209    /// Output glob patterns. Files/dirs produced by the task.
210    #[serde(default)]
211    pub outputs: Vec<String>,
212
213    /// Whether this task's output is cacheable. Default: true.
214    #[serde(default = "default_true")]
215    pub cache: bool,
216
217    /// Long-running / persistent task (e.g. dev server). Won't block downstream tasks.
218    #[serde(default)]
219    pub persistent: bool,
220
221    /// Task-specific environment variables (merged on top of workspace env).
222    #[serde(default)]
223    pub env: std::collections::BTreeMap<String, String>,
224}
225
226fn default_true() -> bool {
227    true
228}
229
230/// Per-project overrides declared under `[projects.<name>]` in `kdo.toml`.
231#[derive(Debug, Clone, Serialize, Deserialize, Default)]
232pub struct ProjectConfig {
233    /// Task overrides for this project.
234    #[serde(default)]
235    pub tasks: std::collections::BTreeMap<String, TaskSpec>,
236
237    /// Project-specific env (merged on top of workspace env, below task env).
238    #[serde(default)]
239    pub env: std::collections::BTreeMap<String, String>,
240}
241
242impl TaskSpec {
243    /// Borrow the command if this task has one.
244    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    /// Task dependencies (possibly empty).
252    pub fn depends_on(&self) -> &[String] {
253        match self {
254            Self::Command(_) => &[],
255            Self::Full(def) => &def.depends_on,
256        }
257    }
258
259    /// Borrow task-level env vars.
260    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    /// Whether the task should not block downstream execution.
270    pub fn persistent(&self) -> bool {
271        matches!(self, Self::Full(def) if def.persistent)
272    }
273}
274
275impl WorkspaceConfig {
276    /// Load workspace config from a `kdo.toml` file.
277    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    /// Write workspace config to a `kdo.toml` file.
286    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    /// Resolve an alias to its real task name. Returns the input unchanged if not aliased.
296    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
301/// Rough token estimator: ~4 characters per token for English/code.
302///
303/// # Examples
304///
305/// ```
306/// use kdo_core::estimate_tokens;
307/// assert_eq!(estimate_tokens("hello world!"), 3); // 12 chars / 4
308/// ```
309pub 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}