cuenv_core/manifest/
mod.rs

1//! Root Cuenv configuration type
2//!
3//! Based on schema/cuenv.cue
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::ci::CI;
10use crate::config::Config;
11use crate::environment::Env;
12use crate::hooks::Hook;
13use crate::tasks::{Input, Mapping, ProjectReference, TaskGroup};
14use crate::tasks::{Task, TaskDefinition};
15
16/// Workspace configuration
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
18#[serde(rename_all = "camelCase")]
19pub struct WorkspaceConfig {
20    /// Enable or disable the workspace
21    #[serde(default = "default_true")]
22    pub enabled: bool,
23
24    /// Optional: manually specify the root of the workspace relative to env.cue
25    pub root: Option<String>,
26
27    /// Optional: manually specify the package manager
28    pub package_manager: Option<String>,
29}
30
31fn default_true() -> bool {
32    true
33}
34
35/// Collection of hooks that can be executed
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
37pub struct Hooks {
38    /// Named hooks to execute when entering an environment (map of name -> hook)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    #[serde(rename = "onEnter")]
41    pub on_enter: Option<HashMap<String, Hook>>,
42
43    /// Named hooks to execute when exiting an environment (map of name -> hook)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    #[serde(rename = "onExit")]
46    pub on_exit: Option<HashMap<String, Hook>>,
47}
48
49/// Root Cuenv configuration structure
50#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
51pub struct Cuenv {
52    /// Configuration settings
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub config: Option<Config>,
55
56    /// Project name (unique identifier)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub name: Option<String>,
59
60    /// Environment variables configuration
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub env: Option<Env>,
63
64    /// Hooks configuration
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub hooks: Option<Hooks>,
67
68    /// Workspaces configuration
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub workspaces: Option<HashMap<String, WorkspaceConfig>>,
71
72    /// CI configuration
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub ci: Option<CI>,
75
76    /// Tasks configuration
77    #[serde(default)]
78    pub tasks: HashMap<String, TaskDefinition>,
79}
80
81impl Cuenv {
82    /// Create a new empty Cuenv configuration
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Get hooks to execute when entering environment as a map (name -> hook)
88    pub fn on_enter_hooks_map(&self) -> HashMap<String, Hook> {
89        self.hooks
90            .as_ref()
91            .and_then(|h| h.on_enter.as_ref())
92            .cloned()
93            .unwrap_or_default()
94    }
95
96    /// Get hooks to execute when entering environment, sorted by (order, name)
97    pub fn on_enter_hooks(&self) -> Vec<Hook> {
98        let map = self.on_enter_hooks_map();
99        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
100        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
101        hooks.into_iter().map(|(_, h)| h).collect()
102    }
103
104    /// Get hooks to execute when exiting environment as a map (name -> hook)
105    pub fn on_exit_hooks_map(&self) -> HashMap<String, Hook> {
106        self.hooks
107            .as_ref()
108            .and_then(|h| h.on_exit.as_ref())
109            .cloned()
110            .unwrap_or_default()
111    }
112
113    /// Get hooks to execute when exiting environment, sorted by (order, name)
114    pub fn on_exit_hooks(&self) -> Vec<Hook> {
115        let map = self.on_exit_hooks_map();
116        let mut hooks: Vec<(String, Hook)> = map.into_iter().collect();
117        hooks.sort_by(|a, b| a.1.order.cmp(&b.1.order).then(a.0.cmp(&b.0)));
118        hooks.into_iter().map(|(_, h)| h).collect()
119    }
120
121    /// Inject implicit tasks and dependencies based on workspace declarations.
122    ///
123    /// When a workspace is declared (e.g., `workspaces: bun: {}`), this method:
124    /// 1. Creates an install task for that workspace if one doesn't already exist
125    /// 2. Adds the install task as a dependency to all tasks that use that workspace
126    ///
127    /// This ensures users don't need to manually define common tasks like
128    /// `bun.install` or manually wire up dependencies.
129    pub fn with_implicit_tasks(mut self) -> Self {
130        let Some(workspaces) = &self.workspaces else {
131            return self;
132        };
133
134        // Collect which workspaces have known install tasks
135        let mut workspace_install_tasks: HashMap<String, String> = HashMap::new();
136
137        for (name, config) in workspaces {
138            if !config.enabled {
139                continue;
140            }
141
142            // Only known workspace types get implicit install tasks
143            if !matches!(name.as_str(), "bun" | "npm" | "pnpm" | "yarn" | "cargo") {
144                continue;
145            }
146
147            let install_task_name = format!("{}.install", name);
148            workspace_install_tasks.insert(name.clone(), install_task_name.clone());
149
150            // Don't override user-defined tasks
151            if self.tasks.contains_key(&install_task_name) {
152                continue;
153            }
154
155            if let Some(task) = Self::create_implicit_install_task(name) {
156                self.tasks
157                    .insert(install_task_name, TaskDefinition::Single(Box::new(task)));
158            }
159        }
160
161        // Add implicit dependencies: tasks using a workspace should depend on its install task
162        Self::add_implicit_workspace_dependencies(&mut self.tasks, &workspace_install_tasks);
163
164        self
165    }
166
167    /// Add implicit dependencies for tasks that use workspaces.
168    fn add_implicit_workspace_dependencies(
169        tasks: &mut HashMap<String, TaskDefinition>,
170        workspace_install_tasks: &HashMap<String, String>,
171    ) {
172        for (task_name, task_def) in tasks.iter_mut() {
173            Self::add_dependencies_to_definition(task_name, task_def, workspace_install_tasks);
174        }
175    }
176
177    /// Recursively add workspace dependencies to a task definition.
178    fn add_dependencies_to_definition(
179        task_name: &str,
180        task_def: &mut TaskDefinition,
181        workspace_install_tasks: &HashMap<String, String>,
182    ) {
183        use crate::tasks::TaskGroup;
184
185        match task_def {
186            TaskDefinition::Single(task) => {
187                for workspace_name in &task.workspaces {
188                    if let Some(install_task) = workspace_install_tasks.get(workspace_name) {
189                        // Don't add self-dependency (the install task itself)
190                        if task_name == install_task {
191                            continue;
192                        }
193                        // Don't add duplicate dependency
194                        if !task.depends_on.contains(install_task) {
195                            task.depends_on.push(install_task.clone());
196                        }
197                    }
198                }
199            }
200            TaskDefinition::Group(group) => match group {
201                TaskGroup::Sequential(tasks) => {
202                    for (i, sub_task) in tasks.iter_mut().enumerate() {
203                        let sub_name = format!("{}[{}]", task_name, i);
204                        Self::add_dependencies_to_definition(
205                            &sub_name,
206                            sub_task,
207                            workspace_install_tasks,
208                        );
209                    }
210                }
211                TaskGroup::Parallel(tasks) => {
212                    for (name, sub_task) in tasks.iter_mut() {
213                        let sub_name = format!("{}.{}", task_name, name);
214                        Self::add_dependencies_to_definition(
215                            &sub_name,
216                            sub_task,
217                            workspace_install_tasks,
218                        );
219                    }
220                }
221            },
222        }
223    }
224
225    /// Create an implicit install task for a known workspace type.
226    fn create_implicit_install_task(workspace_name: &str) -> Option<Task> {
227        let (command, args, description) = match workspace_name {
228            "bun" => ("bun", vec!["install"], "Install bun dependencies"),
229            "npm" => ("npm", vec!["install"], "Install npm dependencies"),
230            "pnpm" => ("pnpm", vec!["install"], "Install pnpm dependencies"),
231            "yarn" => ("yarn", vec!["install"], "Install yarn dependencies"),
232            "cargo" => ("cargo", vec!["fetch"], "Fetch cargo dependencies"),
233            _ => return None, // Unknown workspace type, don't create implicit task
234        };
235
236        Some(Task {
237            command: command.to_string(),
238            args: args.into_iter().map(String::from).collect(),
239            workspaces: vec![workspace_name.to_string()],
240            hermetic: false, // Install tasks must run in real workspace root
241            description: Some(description.to_string()),
242            ..Default::default()
243        })
244    }
245
246    /// Expand shorthand cross-project references in inputs and implicit dependencies.
247    ///
248    /// Handles inputs in the format: "#project:task:path/to/file"
249    /// Converts them to explicit ProjectReference inputs.
250    /// Also adds implicit dependsOn entries for all project references.
251    pub fn expand_cross_project_references(&mut self) {
252        for (_, task_def) in self.tasks.iter_mut() {
253            Self::expand_task_definition(task_def);
254        }
255    }
256
257    fn expand_task_definition(task_def: &mut TaskDefinition) {
258        match task_def {
259            TaskDefinition::Single(task) => Self::expand_task(task),
260            TaskDefinition::Group(group) => match group {
261                TaskGroup::Sequential(tasks) => {
262                    for sub_task in tasks {
263                        Self::expand_task_definition(sub_task);
264                    }
265                }
266                TaskGroup::Parallel(tasks) => {
267                    for sub_task in tasks.values_mut() {
268                        Self::expand_task_definition(sub_task);
269                    }
270                }
271            },
272        }
273    }
274
275    fn expand_task(task: &mut Task) {
276        let mut new_inputs = Vec::new();
277        let mut implicit_deps = Vec::new();
278
279        // Process existing inputs
280        for input in &task.inputs {
281            match input {
282                Input::Path(path) if path.starts_with('#') => {
283                    // Parse "#project:task:path"
284                    // Remove leading #
285                    let parts: Vec<&str> = path[1..].split(':').collect();
286                    if parts.len() >= 3 {
287                        let project = parts[0].to_string();
288                        let task_name = parts[1].to_string();
289                        // Rejoin the rest as the path (it might contain colons)
290                        let file_path = parts[2..].join(":");
291
292                        new_inputs.push(Input::Project(ProjectReference {
293                            project: project.clone(),
294                            task: task_name.clone(),
295                            map: vec![Mapping {
296                                from: file_path.clone(),
297                                to: file_path,
298                            }],
299                        }));
300
301                        // Add implicit dependency
302                        implicit_deps.push(format!("#{}:{}", project, task_name));
303                    } else if parts.len() == 2 {
304                        // Handle "#project:task" as pure dependency?
305                        // The prompt says: `["#projectName:taskName"]` for dependsOn
306                        // For inputs, it likely expects a file mapping.
307                        // If user puts `["#p:t"]` in inputs, it's invalid as an input unless it maps something.
308                        // Assuming `#p:t:f` is the requirement for inputs.
309                        // Keeping original if not matching pattern (or maybe warning?)
310                        new_inputs.push(input.clone());
311                    } else {
312                        new_inputs.push(input.clone());
313                    }
314                }
315                Input::Project(proj_ref) => {
316                    // Add implicit dependency for explicit project references too
317                    implicit_deps.push(format!("#{}:{}", proj_ref.project, proj_ref.task));
318                    new_inputs.push(input.clone());
319                }
320                _ => new_inputs.push(input.clone()),
321            }
322        }
323
324        task.inputs = new_inputs;
325
326        // Add unique implicit dependencies
327        for dep in implicit_deps {
328            if !task.depends_on.contains(&dep) {
329                task.depends_on.push(dep);
330            }
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_expand_cross_project_references() {
341        let task = Task {
342            inputs: vec![Input::Path("#myproj:build:dist/app.js".to_string())],
343            ..Default::default()
344        };
345
346        let mut cuenv = Cuenv::new();
347        cuenv
348            .tasks
349            .insert("deploy".into(), TaskDefinition::Single(Box::new(task)));
350
351        cuenv.expand_cross_project_references();
352
353        let task_def = cuenv.tasks.get("deploy").unwrap();
354        let task = task_def.as_single().unwrap();
355
356        // Check inputs expansion
357        assert_eq!(task.inputs.len(), 1);
358        match &task.inputs[0] {
359            Input::Project(proj_ref) => {
360                assert_eq!(proj_ref.project, "myproj");
361                assert_eq!(proj_ref.task, "build");
362                assert_eq!(proj_ref.map.len(), 1);
363                assert_eq!(proj_ref.map[0].from, "dist/app.js");
364                assert_eq!(proj_ref.map[0].to, "dist/app.js");
365            }
366            _ => panic!("Expected ProjectReference"),
367        }
368
369        // Check implicit dependency
370        assert_eq!(task.depends_on.len(), 1);
371        assert_eq!(task.depends_on[0], "#myproj:build");
372    }
373
374    #[test]
375    fn test_implicit_bun_install_task() {
376        let mut cuenv = Cuenv::new();
377        cuenv.workspaces = Some(HashMap::from([(
378            "bun".into(),
379            WorkspaceConfig {
380                enabled: true,
381                root: None,
382                package_manager: None,
383            },
384        )]));
385
386        let cuenv = cuenv.with_implicit_tasks();
387        assert!(cuenv.tasks.contains_key("bun.install"));
388
389        let task_def = cuenv.tasks.get("bun.install").unwrap();
390        let task = task_def.as_single().unwrap();
391        assert_eq!(task.command, "bun");
392        assert_eq!(task.args, vec!["install"]);
393        assert_eq!(task.workspaces, vec!["bun"]);
394    }
395
396    #[test]
397    fn test_implicit_npm_install_task() {
398        let mut cuenv = Cuenv::new();
399        cuenv.workspaces = Some(HashMap::from([(
400            "npm".into(),
401            WorkspaceConfig {
402                enabled: true,
403                root: None,
404                package_manager: None,
405            },
406        )]));
407
408        let cuenv = cuenv.with_implicit_tasks();
409        assert!(cuenv.tasks.contains_key("npm.install"));
410    }
411
412    #[test]
413    fn test_implicit_cargo_fetch_task() {
414        let mut cuenv = Cuenv::new();
415        cuenv.workspaces = Some(HashMap::from([(
416            "cargo".into(),
417            WorkspaceConfig {
418                enabled: true,
419                root: None,
420                package_manager: None,
421            },
422        )]));
423
424        let cuenv = cuenv.with_implicit_tasks();
425        assert!(cuenv.tasks.contains_key("cargo.install"));
426
427        let task_def = cuenv.tasks.get("cargo.install").unwrap();
428        let task = task_def.as_single().unwrap();
429        assert_eq!(task.command, "cargo");
430        assert_eq!(task.args, vec!["fetch"]);
431    }
432
433    #[test]
434    fn test_no_override_user_defined_task() {
435        let mut cuenv = Cuenv::new();
436        cuenv.workspaces = Some(HashMap::from([(
437            "bun".into(),
438            WorkspaceConfig {
439                enabled: true,
440                root: None,
441                package_manager: None,
442            },
443        )]));
444
445        // User defines their own bun.install task
446        let user_task = Task {
447            command: "custom-bun".to_string(),
448            args: vec!["custom-install".to_string()],
449            ..Default::default()
450        };
451        cuenv.tasks.insert(
452            "bun.install".into(),
453            TaskDefinition::Single(Box::new(user_task)),
454        );
455
456        let cuenv = cuenv.with_implicit_tasks();
457
458        // User's task should not be overridden
459        let task_def = cuenv.tasks.get("bun.install").unwrap();
460        let task = task_def.as_single().unwrap();
461        assert_eq!(task.command, "custom-bun");
462    }
463
464    #[test]
465    fn test_disabled_workspace_no_implicit_task() {
466        let mut cuenv = Cuenv::new();
467        cuenv.workspaces = Some(HashMap::from([(
468            "bun".into(),
469            WorkspaceConfig {
470                enabled: false,
471                root: None,
472                package_manager: None,
473            },
474        )]));
475
476        let cuenv = cuenv.with_implicit_tasks();
477        assert!(!cuenv.tasks.contains_key("bun.install"));
478    }
479
480    #[test]
481    fn test_unknown_workspace_no_implicit_task() {
482        let mut cuenv = Cuenv::new();
483        cuenv.workspaces = Some(HashMap::from([(
484            "unknown-package-manager".into(),
485            WorkspaceConfig {
486                enabled: true,
487                root: None,
488                package_manager: None,
489            },
490        )]));
491
492        let cuenv = cuenv.with_implicit_tasks();
493        assert!(!cuenv.tasks.contains_key("unknown-package-manager.install"));
494    }
495
496    #[test]
497    fn test_no_workspaces_unchanged() {
498        let cuenv = Cuenv::new();
499        let cuenv = cuenv.with_implicit_tasks();
500        assert!(cuenv.tasks.is_empty());
501    }
502
503    #[test]
504    fn test_implicit_dependency_added_to_workspace_task() {
505        let mut cuenv = Cuenv::new();
506        cuenv.workspaces = Some(HashMap::from([(
507            "bun".into(),
508            WorkspaceConfig {
509                enabled: true,
510                root: None,
511                package_manager: None,
512            },
513        )]));
514
515        // User defines a task that uses the bun workspace
516        let user_task = Task {
517            command: "bun".to_string(),
518            args: vec!["run".to_string(), "dev".to_string()],
519            workspaces: vec!["bun".to_string()],
520            ..Default::default()
521        };
522        cuenv
523            .tasks
524            .insert("dev".into(), TaskDefinition::Single(Box::new(user_task)));
525
526        let cuenv = cuenv.with_implicit_tasks();
527
528        // The dev task should now depend on bun.install
529        let task_def = cuenv.tasks.get("dev").unwrap();
530        let task = task_def.as_single().unwrap();
531        assert!(
532            task.depends_on.contains(&"bun.install".to_string()),
533            "Task using bun workspace should auto-depend on bun.install"
534        );
535    }
536
537    #[test]
538    fn test_install_task_does_not_depend_on_itself() {
539        let mut cuenv = Cuenv::new();
540        cuenv.workspaces = Some(HashMap::from([(
541            "bun".into(),
542            WorkspaceConfig {
543                enabled: true,
544                root: None,
545                package_manager: None,
546            },
547        )]));
548
549        let cuenv = cuenv.with_implicit_tasks();
550
551        // The bun.install task should NOT depend on itself
552        let task_def = cuenv.tasks.get("bun.install").unwrap();
553        let task = task_def.as_single().unwrap();
554        assert!(
555            !task.depends_on.contains(&"bun.install".to_string()),
556            "Install task should not depend on itself"
557        );
558    }
559
560    #[test]
561    fn test_no_duplicate_dependencies() {
562        let mut cuenv = Cuenv::new();
563        cuenv.workspaces = Some(HashMap::from([(
564            "bun".into(),
565            WorkspaceConfig {
566                enabled: true,
567                root: None,
568                package_manager: None,
569            },
570        )]));
571
572        // User defines a task that already depends on bun.install
573        let user_task = Task {
574            command: "bun".to_string(),
575            args: vec!["run".to_string(), "dev".to_string()],
576            workspaces: vec!["bun".to_string()],
577            depends_on: vec!["bun.install".to_string()],
578            ..Default::default()
579        };
580        cuenv
581            .tasks
582            .insert("dev".into(), TaskDefinition::Single(Box::new(user_task)));
583
584        let cuenv = cuenv.with_implicit_tasks();
585
586        // Should not add duplicate dependency
587        let task_def = cuenv.tasks.get("dev").unwrap();
588        let task = task_def.as_single().unwrap();
589        let count = task
590            .depends_on
591            .iter()
592            .filter(|d| *d == "bun.install")
593            .count();
594        assert_eq!(count, 1, "Should not have duplicate bun.install dependency");
595    }
596}