souk_core/ci/
install_workflows.rs1use std::fs;
8use std::path::Path;
9
10use crate::error::SoukError;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CiProvider {
15 GitHub,
17 Blacksmith,
19 Northflank,
21 CircleCi,
23 GitLab,
25 Buildkite,
27}
28
29impl CiProvider {
30 pub fn name(&self) -> &str {
32 match self {
33 CiProvider::GitHub => "github",
34 CiProvider::Blacksmith => "blacksmith",
35 CiProvider::Northflank => "northflank",
36 CiProvider::CircleCi => "circleci",
37 CiProvider::GitLab => "gitlab",
38 CiProvider::Buildkite => "buildkite",
39 }
40 }
41}
42
43impl std::fmt::Display for CiProvider {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "{}", self.name())
46 }
47}
48
49pub fn detect_ci_provider(project_root: &Path) -> Option<CiProvider> {
59 if project_root.join(".github").join("workflows").is_dir() {
60 Some(CiProvider::GitHub)
61 } else if project_root.join(".circleci").is_dir() {
62 Some(CiProvider::CircleCi)
63 } else if project_root.join(".gitlab-ci.yml").exists() {
64 Some(CiProvider::GitLab)
65 } else if project_root.join(".buildkite").is_dir() {
66 Some(CiProvider::Buildkite)
67 } else {
68 None
69 }
70}
71
72pub fn install_workflow(project_root: &Path, provider: &CiProvider) -> Result<String, SoukError> {
77 match provider {
78 CiProvider::GitHub | CiProvider::Blacksmith | CiProvider::Northflank => {
79 install_github_workflow(project_root)
80 }
81 CiProvider::CircleCi => install_circleci_config(project_root),
82 CiProvider::GitLab => install_gitlab_config(project_root),
83 CiProvider::Buildkite => install_buildkite_config(project_root),
84 }
85}
86
87const GITHUB_WORKFLOW: &str = r#"name: Souk Marketplace Validation
89
90on:
91 push:
92 paths:
93 - '.claude-plugin/**'
94 - 'plugins/**'
95 pull_request:
96 paths:
97 - '.claude-plugin/**'
98 - 'plugins/**'
99
100jobs:
101 validate:
102 runs-on: ubuntu-latest
103 steps:
104 - uses: actions/checkout@v4
105
106 - name: Install souk
107 run: cargo install souk
108
109 - name: Validate marketplace
110 run: souk validate marketplace
111"#;
112
113fn install_github_workflow(project_root: &Path) -> Result<String, SoukError> {
115 let workflows_dir = project_root.join(".github").join("workflows");
116 fs::create_dir_all(&workflows_dir)?;
117
118 let workflow_path = workflows_dir.join("souk-validate.yml");
119
120 if workflow_path.exists() {
121 let existing = fs::read_to_string(&workflow_path)?;
122 if existing.contains("souk validate marketplace") {
123 return Ok(format!(
124 "GitHub Actions workflow already exists at {}",
125 workflow_path.display()
126 ));
127 }
128 }
129
130 fs::write(&workflow_path, GITHUB_WORKFLOW)?;
131
132 Ok(format!(
133 "Created GitHub Actions workflow at {}",
134 workflow_path.display()
135 ))
136}
137
138const CIRCLECI_CONFIG: &str = r#"version: 2.1
140
141jobs:
142 souk-validate:
143 docker:
144 - image: cimg/rust:1.80
145 steps:
146 - checkout
147 - run:
148 name: Install souk
149 command: cargo install souk
150 - run:
151 name: Validate marketplace
152 command: souk validate marketplace
153
154workflows:
155 validate:
156 jobs:
157 - souk-validate:
158 filters:
159 branches:
160 only: /.*/
161"#;
162
163fn install_circleci_config(project_root: &Path) -> Result<String, SoukError> {
168 let circleci_dir = project_root.join(".circleci");
169 fs::create_dir_all(&circleci_dir)?;
170
171 let config_path = circleci_dir.join("config.yml");
172
173 if config_path.exists() {
174 let existing = fs::read_to_string(&config_path)?;
175 if existing.contains("souk-validate") {
176 return Ok(format!(
177 "CircleCI souk-validate job already exists in {}",
178 config_path.display()
179 ));
180 }
181
182 let snippet = "\n# --- Souk validation (merge into your config) ---\n\
184 # Add the following job to your existing workflows:\n\
185 #\n\
186 # jobs:\n\
187 # souk-validate:\n\
188 # docker:\n\
189 # - image: cimg/rust:1.80\n\
190 # steps:\n\
191 # - checkout\n\
192 # - run:\n\
193 # name: Install souk\n\
194 # command: cargo install souk\n\
195 # - run:\n\
196 # name: Validate marketplace\n\
197 # command: souk validate marketplace\n";
198 let new_content = format!("{existing}{snippet}");
199 fs::write(&config_path, new_content)?;
200
201 return Ok(format!(
202 "Appended souk validation job (commented) to {}. \
203 Please merge into your existing CircleCI configuration.",
204 config_path.display()
205 ));
206 }
207
208 fs::write(&config_path, CIRCLECI_CONFIG)?;
209
210 Ok(format!(
211 "Created CircleCI configuration at {}",
212 config_path.display()
213 ))
214}
215
216const GITLAB_CONFIG: &str = r#"souk-validate:
218 stage: test
219 image: rust:1.80
220 script:
221 - cargo install souk
222 - souk validate marketplace
223 rules:
224 - changes:
225 - .claude-plugin/**/*
226 - plugins/**/*
227"#;
228
229fn install_gitlab_config(project_root: &Path) -> Result<String, SoukError> {
233 let config_path = project_root.join(".gitlab-ci.yml");
234
235 if config_path.exists() {
236 let existing = fs::read_to_string(&config_path)?;
237 if existing.contains("souk-validate") {
238 return Ok(format!(
239 "GitLab CI souk-validate job already exists in {}",
240 config_path.display()
241 ));
242 }
243
244 let new_content = format!("{existing}\n{GITLAB_CONFIG}");
245 fs::write(&config_path, new_content)?;
246
247 return Ok(format!(
248 "Appended souk-validate job to {}",
249 config_path.display()
250 ));
251 }
252
253 fs::write(&config_path, GITLAB_CONFIG)?;
254
255 Ok(format!(
256 "Created GitLab CI configuration at {}",
257 config_path.display()
258 ))
259}
260
261const BUILDKITE_PIPELINE: &str = r#"steps:
263 - label: ":souk: Validate Marketplace"
264 command:
265 - cargo install souk
266 - souk validate marketplace
267 agents:
268 queue: default
269"#;
270
271fn install_buildkite_config(project_root: &Path) -> Result<String, SoukError> {
273 let buildkite_dir = project_root.join(".buildkite");
274 fs::create_dir_all(&buildkite_dir)?;
275
276 let pipeline_path = buildkite_dir.join("pipeline.yml");
277
278 if pipeline_path.exists() {
279 let existing = fs::read_to_string(&pipeline_path)?;
280 if existing.contains("souk validate marketplace")
281 || existing.contains("Validate Marketplace")
282 {
283 return Ok(format!(
284 "Buildkite souk validation step already exists in {}",
285 pipeline_path.display()
286 ));
287 }
288 }
289
290 fs::write(&pipeline_path, BUILDKITE_PIPELINE)?;
291
292 Ok(format!(
293 "Created Buildkite pipeline at {}",
294 pipeline_path.display()
295 ))
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use tempfile::TempDir;
302
303 #[test]
304 fn detect_ci_provider_finds_github_workflows() {
305 let tmp = TempDir::new().unwrap();
306 fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap();
307 assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitHub));
308 }
309
310 #[test]
311 fn detect_ci_provider_finds_circleci() {
312 let tmp = TempDir::new().unwrap();
313 fs::create_dir(tmp.path().join(".circleci")).unwrap();
314 assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::CircleCi));
315 }
316
317 #[test]
318 fn detect_ci_provider_finds_gitlab() {
319 let tmp = TempDir::new().unwrap();
320 fs::write(tmp.path().join(".gitlab-ci.yml"), "").unwrap();
321 assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitLab));
322 }
323
324 #[test]
325 fn detect_ci_provider_finds_buildkite() {
326 let tmp = TempDir::new().unwrap();
327 fs::create_dir(tmp.path().join(".buildkite")).unwrap();
328 assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::Buildkite));
329 }
330
331 #[test]
332 fn detect_ci_provider_returns_none_for_empty_dir() {
333 let tmp = TempDir::new().unwrap();
334 assert_eq!(detect_ci_provider(tmp.path()), None);
335 }
336
337 #[test]
338 fn install_github_workflow_creates_workflow_file() {
339 let tmp = TempDir::new().unwrap();
340
341 let result = install_github_workflow(tmp.path()).unwrap();
342 assert!(result.contains("Created GitHub Actions workflow"));
343
344 let workflow_path = tmp.path().join(".github/workflows/souk-validate.yml");
345 assert!(workflow_path.exists());
346
347 let content = fs::read_to_string(&workflow_path).unwrap();
348 assert!(content.contains("Souk Marketplace Validation"));
349 assert!(content.contains("souk validate marketplace"));
350 assert!(content.contains("actions/checkout@v4"));
351 assert!(content.contains("cargo install souk"));
352 }
353
354 #[test]
355 fn install_github_workflow_skips_if_exists() {
356 let tmp = TempDir::new().unwrap();
357 let workflows_dir = tmp.path().join(".github/workflows");
358 fs::create_dir_all(&workflows_dir).unwrap();
359 fs::write(
360 workflows_dir.join("souk-validate.yml"),
361 "name: existing\nrun: souk validate marketplace\n",
362 )
363 .unwrap();
364
365 let result = install_github_workflow(tmp.path()).unwrap();
366 assert!(result.contains("already exists"));
367 }
368
369 #[test]
370 fn install_circleci_config_creates_config_file() {
371 let tmp = TempDir::new().unwrap();
372
373 let result = install_circleci_config(tmp.path()).unwrap();
374 assert!(result.contains("Created CircleCI configuration"));
375
376 let config_path = tmp.path().join(".circleci/config.yml");
377 assert!(config_path.exists());
378
379 let content = fs::read_to_string(&config_path).unwrap();
380 assert!(content.contains("souk-validate"));
381 assert!(content.contains("cargo install souk"));
382 assert!(content.contains("souk validate marketplace"));
383 }
384
385 #[test]
386 fn install_circleci_config_appends_to_existing() {
387 let tmp = TempDir::new().unwrap();
388 let circleci_dir = tmp.path().join(".circleci");
389 fs::create_dir(&circleci_dir).unwrap();
390 fs::write(
391 circleci_dir.join("config.yml"),
392 "version: 2.1\njobs:\n build:\n steps: []\n",
393 )
394 .unwrap();
395
396 let result = install_circleci_config(tmp.path()).unwrap();
397 assert!(result.contains("Appended"));
398
399 let content = fs::read_to_string(circleci_dir.join("config.yml")).unwrap();
400 assert!(content.contains("version: 2.1"));
401 assert!(content.contains("Souk validation"));
402 }
403
404 #[test]
405 fn install_gitlab_config_creates_file() {
406 let tmp = TempDir::new().unwrap();
407
408 let result = install_gitlab_config(tmp.path()).unwrap();
409 assert!(result.contains("Created GitLab CI configuration"));
410
411 let config_path = tmp.path().join(".gitlab-ci.yml");
412 assert!(config_path.exists());
413
414 let content = fs::read_to_string(&config_path).unwrap();
415 assert!(content.contains("souk-validate"));
416 assert!(content.contains("souk validate marketplace"));
417 }
418
419 #[test]
420 fn install_gitlab_config_appends_to_existing() {
421 let tmp = TempDir::new().unwrap();
422 fs::write(
423 tmp.path().join(".gitlab-ci.yml"),
424 "stages:\n - test\n\nbuild:\n script: echo ok\n",
425 )
426 .unwrap();
427
428 let result = install_gitlab_config(tmp.path()).unwrap();
429 assert!(result.contains("Appended"));
430
431 let content = fs::read_to_string(tmp.path().join(".gitlab-ci.yml")).unwrap();
432 assert!(content.contains("stages:"));
433 assert!(content.contains("souk-validate"));
434 }
435
436 #[test]
437 fn install_buildkite_config_creates_pipeline() {
438 let tmp = TempDir::new().unwrap();
439
440 let result = install_buildkite_config(tmp.path()).unwrap();
441 assert!(result.contains("Created Buildkite pipeline"));
442
443 let pipeline_path = tmp.path().join(".buildkite/pipeline.yml");
444 assert!(pipeline_path.exists());
445
446 let content = fs::read_to_string(&pipeline_path).unwrap();
447 assert!(content.contains("Validate Marketplace"));
448 assert!(content.contains("souk validate marketplace"));
449 }
450
451 #[test]
452 fn ci_provider_name_returns_expected_values() {
453 assert_eq!(CiProvider::GitHub.name(), "github");
454 assert_eq!(CiProvider::Blacksmith.name(), "blacksmith");
455 assert_eq!(CiProvider::Northflank.name(), "northflank");
456 assert_eq!(CiProvider::CircleCi.name(), "circleci");
457 assert_eq!(CiProvider::GitLab.name(), "gitlab");
458 assert_eq!(CiProvider::Buildkite.name(), "buildkite");
459 }
460
461 #[test]
462 fn ci_provider_display() {
463 assert_eq!(format!("{}", CiProvider::GitHub), "github");
464 assert_eq!(format!("{}", CiProvider::CircleCi), "circleci");
465 }
466
467 #[test]
468 fn install_workflow_dispatches_to_github_for_compatible_providers() {
469 let tmp = TempDir::new().unwrap();
470
471 for provider in &[
473 CiProvider::GitHub,
474 CiProvider::Blacksmith,
475 CiProvider::Northflank,
476 ] {
477 let tmp_inner = TempDir::new().unwrap();
478 let result = install_workflow(tmp_inner.path(), provider).unwrap();
479 assert!(result.contains("GitHub Actions workflow"));
480
481 let workflow_path = tmp_inner.path().join(".github/workflows/souk-validate.yml");
482 assert!(workflow_path.exists());
483 }
484
485 drop(tmp);
487 }
488
489 #[test]
490 fn detect_ci_provider_github_takes_priority_over_others() {
491 let tmp = TempDir::new().unwrap();
492 fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap();
494 fs::write(tmp.path().join(".gitlab-ci.yml"), "").unwrap();
495
496 assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitHub));
498 }
499}