1use alloc::{
7 collections::BTreeMap,
8 string::{String, ToString},
9 vec::Vec,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub(crate) enum JobStatus {
15 Pending,
16 Running,
17 Passed,
18 Failed,
19 Skipped,
20}
21
22#[derive(Debug, Clone)]
24pub(crate) struct JobStep {
25 pub(crate) name: String,
26 pub(crate) command: String,
27 pub(crate) status: JobStatus,
28 pub(crate) output: String,
29 pub(crate) exit_code: Option<i32>,
30}
31
32impl JobStep {
33 pub(crate) fn new(name: &str, command: &str) -> Self {
34 Self {
35 name: name.to_string(),
36 command: command.to_string(),
37 status: JobStatus::Pending,
38 output: String::new(),
39 exit_code: None,
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub(crate) struct Job {
47 pub(crate) name: String,
48 pub(crate) steps: Vec<JobStep>,
49 pub(crate) status: JobStatus,
50 pub(crate) environment: BTreeMap<String, String>,
51 pub(crate) artifacts: Vec<String>,
52 pub(crate) dependencies: Vec<String>,
53 pub(crate) allow_failure: bool,
54}
55
56impl Job {
57 pub(crate) fn new(name: &str) -> Self {
58 Self {
59 name: name.to_string(),
60 steps: Vec::new(),
61 status: JobStatus::Pending,
62 environment: BTreeMap::new(),
63 artifacts: Vec::new(),
64 dependencies: Vec::new(),
65 allow_failure: false,
66 }
67 }
68
69 pub(crate) fn add_step(&mut self, name: &str, command: &str) {
70 self.steps.push(JobStep::new(name, command));
71 }
72
73 pub(crate) fn execute(&mut self) -> bool {
75 self.status = JobStatus::Running;
76
77 for step in &mut self.steps {
78 step.status = JobStatus::Running;
79 step.status = JobStatus::Passed;
81 step.exit_code = Some(0);
82 step.output = alloc::format!("$ {}\n[ok]", step.command);
83 }
84
85 let all_passed = self.steps.iter().all(|s| s.status == JobStatus::Passed);
86 self.status = if all_passed || self.allow_failure {
87 JobStatus::Passed
88 } else {
89 JobStatus::Failed
90 };
91
92 all_passed
93 }
94
95 pub(crate) fn step_count(&self) -> usize {
96 self.steps.len()
97 }
98}
99
100pub(crate) struct Pipeline {
102 pub(crate) name: String,
103 pub(crate) jobs: Vec<Job>,
104 pub(crate) trigger: PipelineTrigger,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub(crate) enum PipelineTrigger {
109 Push,
110 PullRequest,
111 Manual,
112 Schedule(String),
113}
114
115impl Pipeline {
116 pub(crate) fn new(name: &str) -> Self {
117 Self {
118 name: name.to_string(),
119 jobs: Vec::new(),
120 trigger: PipelineTrigger::Manual,
121 }
122 }
123
124 pub(crate) fn add_job(&mut self, job: Job) {
125 self.jobs.push(job);
126 }
127
128 pub(crate) fn execute(&mut self) -> bool {
130 let mut all_passed = true;
131 let mut completed: Vec<String> = Vec::new();
132
133 for job in &mut self.jobs {
134 let deps_met = job.dependencies.iter().all(|d| completed.contains(d));
136 if !deps_met {
137 job.status = JobStatus::Skipped;
138 continue;
139 }
140
141 if !job.execute() && !job.allow_failure {
142 all_passed = false;
143 }
144 completed.push(job.name.clone());
145 }
146
147 all_passed
148 }
149
150 pub(crate) fn job_count(&self) -> usize {
151 self.jobs.len()
152 }
153
154 pub(crate) fn passed_count(&self) -> usize {
155 self.jobs
156 .iter()
157 .filter(|j| j.status == JobStatus::Passed)
158 .count()
159 }
160
161 pub(crate) fn failed_count(&self) -> usize {
162 self.jobs
163 .iter()
164 .filter(|j| j.status == JobStatus::Failed)
165 .count()
166 }
167}
168
169#[derive(Debug, Clone)]
171pub(crate) struct Artifact {
172 pub(crate) name: String,
173 pub(crate) size: u64,
174 pub(crate) job_id: String,
175 pub(crate) pipeline_id: String,
176 pub(crate) created_tick: u64,
177 pub(crate) data_hash: [u8; 32],
178}
179
180impl Artifact {
181 pub(crate) fn new(name: &str, job_id: &str, pipeline_id: &str) -> Self {
182 Self {
183 name: name.to_string(),
184 size: 0,
185 job_id: job_id.to_string(),
186 pipeline_id: pipeline_id.to_string(),
187 created_tick: 0,
188 data_hash: [0u8; 32],
189 }
190 }
191}
192
193pub(crate) struct ArtifactStore {
195 artifacts: BTreeMap<String, Artifact>,
196}
197
198impl Default for ArtifactStore {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl ArtifactStore {
205 pub(crate) fn new() -> Self {
206 Self {
207 artifacts: BTreeMap::new(),
208 }
209 }
210
211 pub(crate) fn store_artifact(
213 &mut self,
214 name: &str,
215 data_hash: [u8; 32],
216 size: u64,
217 job_id: &str,
218 pipeline_id: &str,
219 tick: u64,
220 ) {
221 let mut artifact = Artifact::new(name, job_id, pipeline_id);
222 artifact.data_hash = data_hash;
223 artifact.size = size;
224 artifact.created_tick = tick;
225 self.artifacts.insert(name.to_string(), artifact);
226 }
227
228 pub(crate) fn get_artifact(&self, name: &str) -> Option<&Artifact> {
230 self.artifacts.get(name)
231 }
232
233 pub(crate) fn list_artifacts(&self) -> Vec<&Artifact> {
235 self.artifacts.values().collect()
236 }
237
238 pub(crate) fn cleanup_old(&mut self, current_tick: u64, retention_ticks: u64) -> usize {
240 let cutoff = current_tick.saturating_sub(retention_ticks);
241 let before = self.artifacts.len();
242 self.artifacts.retain(|_, a| a.created_tick >= cutoff);
243 before - self.artifacts.len()
244 }
245
246 pub(crate) fn count(&self) -> usize {
247 self.artifacts.len()
248 }
249}
250
251#[derive(Debug, Clone)]
253pub(crate) struct GitPoller {
254 pub(crate) repo_url: String,
255 pub(crate) branch: String,
256 pub(crate) last_commit_hash: String,
257 pub(crate) poll_interval_ticks: u64,
258 last_poll_tick: u64,
259}
260
261impl GitPoller {
262 pub(crate) fn new(repo_url: &str, branch: &str, poll_interval: u64) -> Self {
263 Self {
264 repo_url: repo_url.to_string(),
265 branch: branch.to_string(),
266 last_commit_hash: String::new(),
267 poll_interval_ticks: poll_interval,
268 last_poll_tick: 0,
269 }
270 }
271
272 pub(crate) fn should_poll(&self, current_tick: u64) -> bool {
274 current_tick.saturating_sub(self.last_poll_tick) >= self.poll_interval_ticks
275 }
276
277 pub(crate) fn check_for_updates(&mut self, current_head: &str, current_tick: u64) -> bool {
282 self.last_poll_tick = current_tick;
283 if !current_head.is_empty() && current_head != self.last_commit_hash {
284 self.last_commit_hash = current_head.to_string();
285 true
286 } else {
287 false
288 }
289 }
290
291 pub(crate) fn trigger_pipeline(&self, name: &str) -> Pipeline {
294 let mut pipeline = Pipeline::new(name);
295 pipeline.trigger = PipelineTrigger::Push;
296 pipeline
297 }
298}
299
300pub(crate) struct NamespaceIsolation {
302 pub(crate) sandbox_id: u64,
303 pub(crate) active: bool,
304}
305
306impl Default for NamespaceIsolation {
307 fn default() -> Self {
308 Self::new()
309 }
310}
311
312impl NamespaceIsolation {
313 pub(crate) fn new() -> Self {
314 Self {
315 sandbox_id: 0,
316 active: false,
317 }
318 }
319
320 pub(crate) fn create_sandbox(&mut self, job_name: &str) -> u64 {
322 let mut hash: u64 = 5381;
324 for byte in job_name.bytes() {
325 hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
326 }
327 self.sandbox_id = hash;
328 self.active = true;
329 self.sandbox_id
330 }
331
332 pub(crate) fn cleanup_sandbox(&mut self) {
334 self.active = false;
335 self.sandbox_id = 0;
336 }
337
338 pub(crate) fn is_active(&self) -> bool {
339 self.active
340 }
341}
342
343#[derive(Debug, Clone)]
345pub(crate) struct PipelineReport {
346 pub(crate) pipeline_name: String,
347 pub(crate) total_jobs: usize,
348 pub(crate) passed: usize,
349 pub(crate) failed: usize,
350 pub(crate) skipped: usize,
351 pub(crate) artifacts_collected: usize,
352}
353
354impl Pipeline {
355 pub(crate) fn run_with_polling(
360 &mut self,
361 poller: &mut GitPoller,
362 current_head: &str,
363 current_tick: u64,
364 store: &mut ArtifactStore,
365 ) -> Option<PipelineReport> {
366 if !poller.should_poll(current_tick) {
367 return None;
368 }
369 if !poller.check_for_updates(current_head, current_tick) {
370 return None;
371 }
372
373 self.execute();
374 let artifacts = self.collect_artifacts(store, current_tick);
375 Some(self.generate_report(artifacts))
376 }
377
378 pub(crate) fn collect_artifacts(&self, store: &mut ArtifactStore, tick: u64) -> usize {
380 let mut count = 0;
381 for job in &self.jobs {
382 if job.status != JobStatus::Passed {
383 continue;
384 }
385 for artifact_name in &job.artifacts {
386 store.store_artifact(
387 artifact_name,
388 [0u8; 32], 0,
390 &job.name,
391 &self.name,
392 tick,
393 );
394 count += 1;
395 }
396 }
397 count
398 }
399
400 pub(crate) fn generate_report(&self, artifacts_collected: usize) -> PipelineReport {
402 PipelineReport {
403 pipeline_name: self.name.clone(),
404 total_jobs: self.jobs.len(),
405 passed: self.passed_count(),
406 failed: self.failed_count(),
407 skipped: self
408 .jobs
409 .iter()
410 .filter(|j| j.status == JobStatus::Skipped)
411 .count(),
412 artifacts_collected,
413 }
414 }
415}
416
417pub(crate) fn parse_ci_config(config: &str) -> Vec<Job> {
419 let mut jobs = Vec::new();
420 let mut current_job: Option<Job> = None;
421
422 for line in config.lines() {
423 let line = line.trim();
424 if line.is_empty() || line.starts_with('#') {
425 continue;
426 }
427
428 if let Some(name) = line.strip_prefix("[job.") {
429 let name = name.trim_end_matches(']');
430 if let Some(job) = current_job.take() {
431 jobs.push(job);
432 }
433 current_job = Some(Job::new(name));
434 } else if let Some((key, value)) = line.split_once('=') {
435 let key = key.trim();
436 let value = value.trim().trim_matches('"');
437 if let Some(ref mut job) = current_job {
438 match key {
439 "step" => {
440 let parts: Vec<&str> = value.splitn(2, ':').collect();
441 if parts.len() == 2 {
442 job.add_step(parts[0].trim(), parts[1].trim());
443 } else {
444 job.add_step(value, value);
445 }
446 }
447 "artifact" => job.artifacts.push(value.to_string()),
448 "depends" => job.dependencies.push(value.to_string()),
449 "allow_failure" => job.allow_failure = value == "true",
450 _ => {}
451 }
452 }
453 }
454 }
455
456 if let Some(job) = current_job {
457 jobs.push(job);
458 }
459
460 jobs
461}
462
463#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn test_job_step_new() {
473 let step = JobStep::new("build", "cargo build");
474 assert_eq!(step.name, "build");
475 assert_eq!(step.status, JobStatus::Pending);
476 }
477
478 #[test]
479 fn test_job_execute() {
480 let mut job = Job::new("test");
481 job.add_step("build", "cargo build");
482 job.add_step("test", "cargo test");
483 assert!(job.execute());
484 assert_eq!(job.status, JobStatus::Passed);
485 }
486
487 #[test]
488 fn test_pipeline_execute() {
489 let mut pipeline = Pipeline::new("ci");
490 let mut build = Job::new("build");
491 build.add_step("compile", "cargo build");
492 pipeline.add_job(build);
493
494 let mut test = Job::new("test");
495 test.add_step("run", "cargo test");
496 test.dependencies.push("build".to_string());
497 pipeline.add_job(test);
498
499 assert!(pipeline.execute());
500 assert_eq!(pipeline.passed_count(), 2);
501 }
502
503 #[test]
504 fn test_pipeline_skipped_deps() {
505 let mut pipeline = Pipeline::new("ci");
506 let mut job = Job::new("deploy");
507 job.dependencies.push("missing".to_string());
508 pipeline.add_job(job);
509
510 pipeline.execute();
511 assert_eq!(pipeline.jobs[0].status, JobStatus::Skipped);
512 }
513
514 #[test]
515 fn test_parse_ci_config() {
516 let config = r#"
517[job.build]
518step = "compile: cargo build --release"
519step = "test: cargo test"
520artifact = "target/release/app"
521
522[job.deploy]
523depends = "build"
524step = "deploy: ./deploy.sh"
525"#;
526
527 let jobs = parse_ci_config(config);
528 assert_eq!(jobs.len(), 2);
529 assert_eq!(jobs[0].name, "build");
530 assert_eq!(jobs[0].step_count(), 2);
531 assert_eq!(jobs[0].artifacts.len(), 1);
532 assert_eq!(jobs[1].dependencies.len(), 1);
533 }
534
535 #[test]
536 fn test_job_status_eq() {
537 assert_eq!(JobStatus::Pending, JobStatus::Pending);
538 assert_ne!(JobStatus::Passed, JobStatus::Failed);
539 }
540
541 #[test]
542 fn test_pipeline_trigger() {
543 assert_eq!(PipelineTrigger::Push, PipelineTrigger::Push);
544 assert_ne!(PipelineTrigger::Push, PipelineTrigger::Manual);
545 }
546
547 #[test]
548 fn test_job_allow_failure() {
549 let mut job = Job::new("flaky");
550 job.allow_failure = true;
551 job.add_step("test", "flaky-test");
552 job.execute();
553 assert_eq!(job.status, JobStatus::Passed);
554 }
555
556 #[test]
557 fn test_empty_pipeline() {
558 let mut pipeline = Pipeline::new("empty");
559 assert!(pipeline.execute());
560 assert_eq!(pipeline.job_count(), 0);
561 }
562
563 #[test]
564 fn test_parse_empty_config() {
565 let jobs = parse_ci_config("");
566 assert!(jobs.is_empty());
567 }
568
569 #[test]
570 fn test_parse_config_comments() {
571 let config = "# comment\n[job.test]\nstep = \"run: echo ok\"\n";
572 let jobs = parse_ci_config(config);
573 assert_eq!(jobs.len(), 1);
574 }
575
576 #[test]
577 fn test_pipeline_counts() {
578 let mut pipeline = Pipeline::new("test");
579 let mut j1 = Job::new("a");
580 j1.add_step("s", "cmd");
581 let mut j2 = Job::new("b");
582 j2.add_step("s", "cmd");
583 pipeline.add_job(j1);
584 pipeline.add_job(j2);
585 pipeline.execute();
586 assert_eq!(pipeline.passed_count(), 2);
587 assert_eq!(pipeline.failed_count(), 0);
588 }
589
590 #[test]
591 fn test_artifact_store_basic() {
592 let mut store = ArtifactStore::new();
593 store.store_artifact("app.bin", [0u8; 32], 1024, "build", "ci-1", 100);
594 assert_eq!(store.count(), 1);
595 let art = store.get_artifact("app.bin").unwrap();
596 assert_eq!(art.size, 1024);
597 assert_eq!(art.job_id, "build");
598 }
599
600 #[test]
601 fn test_artifact_store_list() {
602 let mut store = ArtifactStore::new();
603 store.store_artifact("a.bin", [0u8; 32], 10, "j1", "p1", 1);
604 store.store_artifact("b.bin", [0u8; 32], 20, "j2", "p1", 2);
605 assert_eq!(store.list_artifacts().len(), 2);
606 }
607
608 #[test]
609 fn test_artifact_cleanup() {
610 let mut store = ArtifactStore::new();
611 store.store_artifact("old", [0u8; 32], 10, "j1", "p1", 10);
612 store.store_artifact("new", [0u8; 32], 20, "j2", "p1", 100);
613 let removed = store.cleanup_old(110, 50);
614 assert_eq!(removed, 1);
615 assert_eq!(store.count(), 1);
616 assert!(store.get_artifact("new").is_some());
617 }
618
619 #[test]
620 fn test_git_poller_check_updates() {
621 let mut poller = GitPoller::new("https://example.com/repo.git", "main", 100);
622 assert!(poller.check_for_updates("abc123", 100));
623 assert!(!poller.check_for_updates("abc123", 200)); assert!(poller.check_for_updates("def456", 300)); }
626
627 #[test]
628 fn test_git_poller_should_poll() {
629 let poller = GitPoller::new("https://example.com/repo.git", "main", 100);
630 assert!(poller.should_poll(100));
631 assert!(!poller.should_poll(50));
632 }
633
634 #[test]
635 fn test_git_poller_trigger_pipeline() {
636 let poller = GitPoller::new("https://example.com/repo.git", "main", 100);
637 let pipeline = poller.trigger_pipeline("auto-ci");
638 assert_eq!(pipeline.name, "auto-ci");
639 assert_eq!(pipeline.trigger, PipelineTrigger::Push);
640 }
641
642 #[test]
643 fn test_namespace_isolation() {
644 let mut ns = NamespaceIsolation::new();
645 assert!(!ns.is_active());
646 let id = ns.create_sandbox("build-job");
647 assert!(id > 0);
648 assert!(ns.is_active());
649 ns.cleanup_sandbox();
650 assert!(!ns.is_active());
651 }
652
653 #[test]
654 fn test_pipeline_collect_artifacts() {
655 let mut pipeline = Pipeline::new("ci");
656 let mut job = Job::new("build");
657 job.add_step("compile", "cargo build");
658 job.artifacts.push("target/app".to_string());
659 pipeline.add_job(job);
660 pipeline.execute();
661
662 let mut store = ArtifactStore::new();
663 let count = pipeline.collect_artifacts(&mut store, 500);
664 assert_eq!(count, 1);
665 assert!(store.get_artifact("target/app").is_some());
666 }
667
668 #[test]
669 fn test_pipeline_generate_report() {
670 let mut pipeline = Pipeline::new("ci");
671 let mut job = Job::new("build");
672 job.add_step("compile", "cargo build");
673 pipeline.add_job(job);
674 pipeline.execute();
675 let report = pipeline.generate_report(1);
676 assert_eq!(report.pipeline_name, "ci");
677 assert_eq!(report.total_jobs, 1);
678 assert_eq!(report.passed, 1);
679 assert_eq!(report.failed, 0);
680 assert_eq!(report.artifacts_collected, 1);
681 }
682
683 #[test]
684 fn test_run_with_polling_no_update() {
685 let mut pipeline = Pipeline::new("ci");
686 let mut job = Job::new("build");
687 job.add_step("compile", "cargo build");
688 pipeline.add_job(job);
689
690 let mut poller = GitPoller::new("repo", "main", 100);
691 let mut store = ArtifactStore::new();
692
693 let result = pipeline.run_with_polling(&mut poller, "abc", 50, &mut store);
695 assert!(result.is_none());
696
697 let result = pipeline.run_with_polling(&mut poller, "abc", 100, &mut store);
699 assert!(result.is_some());
700 assert_eq!(result.unwrap().passed, 1);
701 }
702}