use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Language {
Rust,
TypeScript,
JavaScript,
Python,
Anchor,
Go,
}
impl std::fmt::Display for Language {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Rust => write!(f, "rust"),
Self::TypeScript => write!(f, "typescript"),
Self::JavaScript => write!(f, "javascript"),
Self::Python => write!(f, "python"),
Self::Anchor => write!(f, "anchor"),
Self::Go => write!(f, "go"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub path: PathBuf,
pub language: Language,
pub manifest_path: PathBuf,
pub context_summary: Option<String>,
pub public_api_files: Vec<PathBuf>,
pub internal_files: Vec<PathBuf>,
pub content_hash: [u8; 32],
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DepKind {
Source,
Build,
Dev,
Cpi,
}
impl std::fmt::Display for DepKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Source => write!(f, "source"),
Self::Build => write!(f, "build"),
Self::Dev => write!(f, "dev"),
Self::Cpi => write!(f, "cpi"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dependency {
pub name: String,
pub version_req: String,
pub kind: DepKind,
pub is_workspace: bool,
pub resolved_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum KdoError {
#[error("workspace manifest not found at {0}")]
ManifestNotFound(PathBuf),
#[error("failed to parse {path}: {source}")]
ParseError {
path: PathBuf,
source: anyhow::Error,
},
#[error("project not found: {0}")]
ProjectNotFound(String),
#[error("circular dependency detected: {0}")]
#[diagnostic(help("break the cycle by extracting shared code into a separate crate"))]
CircularDependency(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkspaceConfig {
pub workspace: WorkspaceMeta,
#[serde(default)]
pub tasks: std::collections::BTreeMap<String, TaskSpec>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub env_files: Vec<String>,
#[serde(default)]
pub aliases: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub projects: std::collections::BTreeMap<String, ProjectConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WorkspaceMeta {
#[serde(default)]
pub name: String,
#[serde(default, rename = "projects")]
pub project_globs: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TaskSpec {
Command(String),
Full(TaskDef),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TaskDef {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub inputs: Vec<String>,
#[serde(default)]
pub outputs: Vec<String>,
#[serde(default = "default_true")]
pub cache: bool,
#[serde(default)]
pub persistent: bool,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
#[serde(default)]
pub tasks: std::collections::BTreeMap<String, TaskSpec>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
}
impl TaskSpec {
pub fn command(&self) -> Option<&str> {
match self {
Self::Command(c) => Some(c.as_str()),
Self::Full(def) => def.command.as_deref(),
}
}
pub fn depends_on(&self) -> &[String] {
match self {
Self::Command(_) => &[],
Self::Full(def) => &def.depends_on,
}
}
pub fn env(&self) -> &std::collections::BTreeMap<String, String> {
static EMPTY: std::sync::OnceLock<std::collections::BTreeMap<String, String>> =
std::sync::OnceLock::new();
match self {
Self::Command(_) => EMPTY.get_or_init(std::collections::BTreeMap::new),
Self::Full(def) => &def.env,
}
}
pub fn persistent(&self) -> bool {
matches!(self, Self::Full(def) if def.persistent)
}
}
impl WorkspaceConfig {
pub fn load(path: &std::path::Path) -> Result<Self, KdoError> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| KdoError::ParseError {
path: path.to_path_buf(),
source: e.into(),
})
}
pub fn save(&self, path: &std::path::Path) -> Result<(), KdoError> {
let content = toml::to_string_pretty(self).map_err(|e| KdoError::ParseError {
path: path.to_path_buf(),
source: e.into(),
})?;
std::fs::write(path, content)?;
Ok(())
}
pub fn resolve_alias<'a>(&'a self, name: &'a str) -> &'a str {
self.aliases.get(name).map(String::as_str).unwrap_or(name)
}
}
pub fn estimate_tokens(s: &str) -> usize {
s.len() / 4
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_estimate_tokens() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("ab"), 0);
assert_eq!(estimate_tokens("hello world!"), 3);
}
#[test]
fn test_language_display() {
assert_eq!(Language::Rust.to_string(), "rust");
assert_eq!(Language::Anchor.to_string(), "anchor");
}
#[test]
fn test_language_serde_roundtrip() {
let lang = Language::TypeScript;
let json = serde_json::to_string(&lang).unwrap();
assert_eq!(json, "\"typescript\"");
let back: Language = serde_json::from_str(&json).unwrap();
assert_eq!(back, lang);
}
#[test]
fn test_dep_kind_display() {
assert_eq!(DepKind::Cpi.to_string(), "cpi");
assert_eq!(DepKind::Source.to_string(), "source");
}
}