Skip to content

Commit 4a82441

Browse files
committed
feat: packages shorthand, approval job handling, cigen_shallow_checkout, per-workflow outputs; migrate DocSpring jobs; split setup/main outputs
1 parent 12be46f commit 4a82441

File tree

14 files changed

+416
-56
lines changed

14 files changed

+416
-56
lines changed

schemas/v1/job-schema.json

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,26 @@
178178
"type": "array",
179179
"description": "Package managers to use for dependency installation",
180180
"items": {
181-
"type": "string",
182-
"enum": ["node", "ruby", "python", "go"]
181+
"oneOf": [
182+
{
183+
"type": "string",
184+
"enum": ["node", "ruby", "python", "go"]
185+
},
186+
{
187+
"type": "object",
188+
"properties": {
189+
"name": {
190+
"type": "string",
191+
"enum": ["node", "ruby", "python", "go"]
192+
},
193+
"path": { "type": "string" }
194+
},
195+
"required": ["name", "path"],
196+
"additionalProperties": false
197+
}
198+
]
183199
},
184-
"uniqueItems": true
200+
"uniqueItems": false
185201
}
186202
]
187203
},

src/commands/generate.rs

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,20 +210,101 @@ fn generate_from_jobs(
210210
}
211211
}
212212

213-
provider
214-
.generate_all(
215-
&loaded_config.config,
216-
&workflows,
217-
&loaded_config.commands,
218-
&output_path,
219-
)
220-
.map_err(|e| anyhow::anyhow!("Failed to generate all workflows: {}", e))?;
213+
// If any workflow has a specific output filename, generate each workflow separately
214+
let mut any_per_workflow = false;
215+
for (workflow_name, workflow_jobs) in workflows.iter() {
216+
let workflow_config_path =
217+
PathBuf::from(format!("workflows/{workflow_name}/config.yml"));
218+
if workflow_config_path.exists() {
219+
let workflow_config_str = std::fs::read_to_string(&workflow_config_path)?;
220+
let workflow_overrides: serde_yaml::Value =
221+
serde_yaml::from_str(&workflow_config_str)?;
222+
let merged_config = loaded_config.config.clone();
223+
let workflow_config: cigen::models::Config =
224+
if let Some(obj) = workflow_overrides.as_mapping() {
225+
let mut base_value = serde_json::to_value(&merged_config)?;
226+
let override_value = serde_json::to_value(obj)?;
227+
if let (Some(base_obj), Some(override_obj)) =
228+
(base_value.as_object_mut(), override_value.as_object())
229+
{
230+
for (key, value) in override_obj {
231+
if !value.is_null() {
232+
base_obj.insert(key.clone(), value.clone());
233+
}
234+
}
235+
}
236+
serde_json::from_value(base_value)?
237+
} else {
238+
merged_config
239+
};
240+
241+
if workflow_config.output_filename.is_some() {
242+
any_per_workflow = true;
243+
// Determine workflow-specific output path
244+
let workflow_output_path =
245+
if let Some(workflow_output) = &workflow_config.output_path {
246+
original_dir_path(Path::new(workflow_output))
247+
} else {
248+
output_path.clone()
249+
};
250+
251+
// Apply package deduplication for this workflow
252+
let mut jobs_copy = workflow_jobs.clone();
253+
if jobs_copy.values().any(|job| job.packages.is_some()) {
254+
let project_root = if current_dir.ends_with(".cigen") {
255+
current_dir.parent().and_then(|p| p.to_str()).unwrap_or(".")
256+
} else {
257+
current_dir.to_str().unwrap_or(".")
258+
};
259+
let deduplicator =
260+
cigen::packages::deduplicator::PackageDeduplicator::new(project_root);
261+
deduplicator.process_jobs(&mut jobs_copy).map_err(|e| {
262+
anyhow::anyhow!("Failed to process package deduplication: {}", e)
263+
})?;
264+
}
221265

222-
println!(
223-
"✅ Generated {} configuration for all workflows to {}",
224-
provider.name(),
225-
output_path.display()
226-
);
266+
provider
267+
.generate_workflow(
268+
&workflow_config,
269+
workflow_name,
270+
&jobs_copy,
271+
&loaded_config.commands,
272+
&workflow_output_path,
273+
)
274+
.map_err(|e| {
275+
anyhow::anyhow!(
276+
"Failed to generate workflow '{}': {}",
277+
workflow_name,
278+
e
279+
)
280+
})?;
281+
282+
println!(
283+
"✅ Generated {} workflow '{}' to {}",
284+
provider.name(),
285+
workflow_name,
286+
workflow_output_path.display()
287+
);
288+
}
289+
}
290+
}
291+
292+
if !any_per_workflow {
293+
provider
294+
.generate_all(
295+
&loaded_config.config,
296+
&workflows,
297+
&loaded_config.commands,
298+
&output_path,
299+
)
300+
.map_err(|e| anyhow::anyhow!("Failed to generate all workflows: {}", e))?;
301+
302+
println!(
303+
"✅ Generated {} configuration for all workflows to {}",
304+
provider.name(),
305+
output_path.display()
306+
);
307+
}
227308
}
228309

229310
Ok(())

src/graph/dependency_graph.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ mod tests {
198198
packages: None,
199199
steps: None,
200200
checkout: None,
201+
job_type: None,
201202
}
202203
}
203204

src/loader/config_loader.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ impl<'a> ConfigLoader<'a> {
4444
let main_config: JsonValue = serde_yaml::from_str(&content)
4545
.with_context(|| format!("Failed to parse {}", config_path.display()))?;
4646

47-
// Load split configs from .cigen/config/
48-
let config_dir = Path::new(".cigen/config");
47+
// Load split configs; support running from project root or inside .cigen
48+
let cwd = std::env::current_dir().unwrap();
49+
let config_dir = if cwd.file_name() == Some(std::ffi::OsStr::new(".cigen")) {
50+
Path::new("config")
51+
} else {
52+
Path::new(".cigen/config")
53+
};
4954
let mut split_configs = Vec::new();
5055

5156
if config_dir.exists() {
@@ -93,8 +98,13 @@ impl<'a> ConfigLoader<'a> {
9398
let processed = self.process_file_content(&config_path, &content)?;
9499
let main_config: JsonValue = serde_yaml::from_str(&processed)?;
95100

96-
// Load split configs with templating from .cigen/config/
97-
let config_dir = Path::new(".cigen/config");
101+
// Load split configs with templating; support running from project root or inside .cigen
102+
let cwd = std::env::current_dir().unwrap();
103+
let config_dir = if cwd.file_name() == Some(std::ffi::OsStr::new(".cigen")) {
104+
Path::new("config")
105+
} else {
106+
Path::new(".cigen/config")
107+
};
98108
let mut split_configs = Vec::new();
99109

100110
if config_dir.exists() {

src/models/job.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,23 @@ pub struct Job {
5252
#[serde(skip_serializing_if = "Option::is_none")]
5353
pub services: Option<Vec<String>>,
5454

55-
#[serde(skip_serializing_if = "Option::is_none")]
56-
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
57-
pub packages: Option<Vec<String>>,
55+
#[serde(
56+
skip_serializing_if = "Option::is_none",
57+
default,
58+
deserialize_with = "deserialize_packages"
59+
)]
60+
pub packages: Option<Vec<PackageSpec>>,
5861

5962
#[serde(skip_serializing_if = "Option::is_none")]
6063
pub steps: Option<Vec<Step>>,
6164

6265
#[serde(skip_serializing_if = "Option::is_none")]
6366
pub checkout: Option<super::config::CheckoutSetting>,
67+
68+
/// CircleCI job type (e.g., approval)
69+
#[serde(skip_serializing_if = "Option::is_none")]
70+
#[serde(rename = "type")]
71+
pub job_type: Option<String>,
6472
}
6573

6674
/// Intermediate parsing structure for cache definitions that handles multiple YAML formats
@@ -205,6 +213,69 @@ impl Job {
205213
}
206214
}
207215

216+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
217+
#[serde(untagged)]
218+
pub enum PackageSpec {
219+
/// Simple shorthand: "node", "ruby", etc.
220+
Simple(String),
221+
/// Extended form with path scoping (e.g., install under a subdirectory)
222+
WithPath { name: String, path: String },
223+
}
224+
225+
impl PackageSpec {
226+
pub fn name(&self) -> &str {
227+
match self {
228+
PackageSpec::Simple(s) => s.as_str(),
229+
PackageSpec::WithPath { name, .. } => name.as_str(),
230+
}
231+
}
232+
233+
pub fn path(&self) -> Option<&str> {
234+
match self {
235+
PackageSpec::Simple(_) => None,
236+
PackageSpec::WithPath { path, .. } => Some(path.as_str()),
237+
}
238+
}
239+
}
240+
241+
fn deserialize_packages<'de, D>(deserializer: D) -> Result<Option<Vec<PackageSpec>>, D::Error>
242+
where
243+
D: Deserializer<'de>,
244+
{
245+
use serde::de::Error;
246+
247+
let value: Option<serde_yaml::Value> = Option::deserialize(deserializer)?;
248+
249+
match value {
250+
None => Ok(None),
251+
Some(serde_yaml::Value::String(s)) => Ok(Some(vec![PackageSpec::Simple(s)])),
252+
Some(serde_yaml::Value::Sequence(seq)) => {
253+
let mut out: Vec<PackageSpec> = Vec::new();
254+
for item in seq {
255+
match item {
256+
serde_yaml::Value::String(s) => out.push(PackageSpec::Simple(s)),
257+
serde_yaml::Value::Mapping(_) => {
258+
let spec: PackageSpec =
259+
serde_yaml::from_value(item).map_err(D::Error::custom)?;
260+
out.push(spec);
261+
}
262+
_ => return Err(D::Error::custom("Invalid packages entry")),
263+
}
264+
}
265+
Ok(Some(out))
266+
}
267+
Some(serde_yaml::Value::Mapping(map)) => {
268+
// Support single object form: packages: { name: node, path: docs }
269+
let spec: PackageSpec = serde_yaml::from_value(serde_yaml::Value::Mapping(map))
270+
.map_err(D::Error::custom)?;
271+
Ok(Some(vec![spec]))
272+
}
273+
Some(_) => Err(D::Error::custom(
274+
"packages must be a string, array, or object",
275+
)),
276+
}
277+
}
278+
208279
/// Custom deserializer for cache definitions that handles multiple YAML formats
209280
fn deserialize_cache_definitions<'de, D>(
210281
deserializer: D,

src/models/tests_packages.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
use crate::models::Job;
2+
3+
#[test]
4+
fn parse_packages_shorthand_string() {
5+
let yaml = r#"
6+
image: cimg/node:18
7+
packages: node
8+
"#;
9+
let job: Job = serde_yaml::from_str(yaml).unwrap();
10+
let pkgs = job.packages.unwrap();
11+
assert_eq!(pkgs.len(), 1);
12+
match &pkgs[0] {
13+
crate::models::job::PackageSpec::Simple(s) => assert_eq!(s, "node"),
14+
_ => panic!("expected simple package spec"),
15+
}
16+
}
17+
18+
#[test]
19+
fn parse_packages_object_with_path() {
20+
let yaml = r#"
21+
image: cimg/node:18
22+
packages:
23+
name: node
24+
path: docs
25+
"#;
26+
let job: Job = serde_yaml::from_str(yaml).unwrap();
27+
let pkgs = job.packages.unwrap();
28+
assert_eq!(pkgs.len(), 1);
29+
match &pkgs[0] {
30+
crate::models::job::PackageSpec::WithPath { name, path } => {
31+
assert_eq!(name, "node");
32+
assert_eq!(path, "docs");
33+
}
34+
_ => panic!("expected with-path package spec"),
35+
}
36+
}
37+

0 commit comments

Comments
 (0)