1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
# Pipeline-level container image declaration

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `(ci.image "alpine")` to `ci.fnl` so pipelines can declare the container image for all their jobs. The executor resolves the image at run time: declared image → `.quire/Dockerfile` on the branch → `"debian"` default.

**Architecture:** Extend the `Registration` Lua module with an `image` function. The image string is stored in a shared `Rc<RefCell<Option<String>>>` (same pattern as `jobs`). After Fennel evaluation, the image is extracted and stored on `Pipeline`. No validation that an image is declared — the executor resolves a default at run time.

**Tech stack:** Rust, mlua (Lua bridge), miette (diagnostics), existing test patterns in `src/ci/pipeline.rs` and `src/ci/lua.rs`.

---

### Task 1: Add `DuplicateImage` validation error variant

**Files:**
- Modify: `src/ci/pipeline.rs` — add one new `ValidationError` variant

- [ ] **Step 1: Write the failing test**

Add to `src/ci/pipeline.rs` tests module:

```rust
#[test]
fn duplicate_image_variant_exists() {
    let err = ValidationError::DuplicateImage {
        span: miette::SourceSpan::from((0, 0)),
    };
    assert!(err.to_string().contains("image"));
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cargo test --lib ci::pipeline::tests::duplicate_image_variant_exists -- --nocapture`
Expected: FAIL — `DuplicateImage` variant does not exist on `ValidationError`

- [ ] **Step 3: Write minimal implementation**

Add one variant to `ValidationError` in `src/ci/pipeline.rs`:

```rust
#[error("Pipeline image declared more than once.")]
DuplicateImage {
    #[label("duplicate image declaration")]
    span: SourceSpan,
},
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cargo test --lib ci::pipeline::tests::duplicate_image_variant_exists -- --nocapture`
Expected: PASS

- [ ] **Step 5: Commit**

```
Add DuplicateImage validation error variant
```

---

### Task 2: Wire `ci.image` into the Registration module and Pipeline

**Files:**
- Modify: `src/ci/pipeline.rs` — make `span_for_line` `pub(super)`, add `image` field and accessor to `Pipeline`, update `load` for new `ParseOutput`
- Modify: `src/ci/lua.rs` — add `image` field to `Registration`, add `register_image` callback, update `parse` return type

- [ ] **Step 1: Write the failing test**

Add to `src/ci/pipeline.rs` tests:

```rust
#[test]
fn load_registers_pipeline_image() {
    let source = r#"(local ci (require :quire.ci))
(ci.image "alpine")
(ci.job :build [:quire/push] (fn [_] nil))"#;
    let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
    assert_eq!(pipeline.image(), Some("alpine"));
}

#[test]
fn load_succeeds_without_image() {
    let source = r#"(local ci (require :quire.ci))
(ci.job :build [:quire/push] (fn [_] nil))"#;
    let pipeline = Pipeline::load(source, "ci.fnl").expect("load should succeed");
    assert_eq!(pipeline.image(), None);
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cargo test --lib ci::pipeline::tests::load_registers_pipeline_image -- --nocapture`
Expected: FAIL — `image()` method does not exist on `Pipeline`

- [ ] **Step 3: Write minimal implementation**

**`src/ci/pipeline.rs` changes:**

Make `span_for_line` visible to `lua.rs`:

```rust
pub(super) fn span_for_line(source: &str, line: u32) -> SourceSpan {
```

Add `image` field to `Pipeline`:

```rust
pub struct Pipeline {
    jobs: Vec<Job>,
    graph: JobGraph,
    node_index: HashMap<String, NodeIndex>,
    fennel: Fennel,
    image: Option<String>,
}
```

Add accessor:

```rust
/// The container image declared via `(ci.image ...)`, if any.
/// The executor resolves the final image at run time:
/// declared image → `.quire/Dockerfile` → default.
pub fn image(&self) -> Option<&str> {
    self.image.as_deref()
}
```

Update `Pipeline::load` to use `ParseOutput`:

```rust
pub(crate) fn load(source: &str, name: &str) -> Result<Pipeline> {
    let fennel = Fennel::new()?;
    let output = lua::parse(&fennel, source, name)?;

    let mut errors = Vec::new();
    let mut jobs = Vec::new();
    for r in output.jobs {
        match r {
            Ok(j) => jobs.push(j),
            Err(e) => errors.push(e),
        }
    }
    let image = output.image;

    let (graph, node_index) = build_graph(&jobs);

    if let Err(post) = validate_post_graph(&jobs, &graph) {
        errors.extend(post);
    }

    if errors.is_empty() {
        Ok(Self {
            jobs,
            graph,
            node_index,
            fennel,
            image,
        })
    } else {
        Err(LoadError {
            src: NamedSource::new(name, source.to_string()),
            errors,
        }
        .into())
    }
}
```

**`src/ci/lua.rs` changes:**

Add image tracking to `Registration`:

```rust
/// A pending image registration extracted from the Lua callback.
struct ImageRegistration {
    name: String,
    line: u32,
}

struct Registration {
    jobs: Rc<RefCell<Vec<std::result::Result<Job, ValidationError>>>>,
    image: Rc<RefCell<Option<ImageRegistration>>>,
    source: Rc<String>,
}
```

Add new return type for `parse`:

```rust
pub(super) struct ParseOutput {
    pub(super) jobs: Vec<std::result::Result<Job, ValidationError>>,
    pub(super) image: Option<String>,
}
```

Update `parse`:

```rust
pub(super) fn parse(
    fennel: &Fennel,
    source: &str,
    name: &str,
) -> Result<ParseOutput> {
    let jobs = Rc::new(RefCell::new(Vec::new()));
    let image = Rc::new(RefCell::new(None));
    let src = Rc::new(source.to_string());

    fennel.eval_raw(source, name, |lua| {
        lua.register_module(
            "quire.ci",
            Registration {
                jobs: jobs.clone(),
                image: image.clone(),
                source: src.clone(),
            },
        )
    })?;

    let image_name = image.borrow().as_ref().map(|i| i.name.clone());
    Ok(ParseOutput {
        jobs: jobs.take(),
        image: image_name,
    })
}
```

Update `IntoLua for Registration` to add `image` to the module table:

```rust
impl IntoLua for Registration {
    fn into_lua(self, lua: &Lua) -> mlua::Result<mlua::Value> {
        lua.set_app_data(self);
        let table = lua.create_table()?;
        table.set("job", lua.create_function(register_job)?)?;
        table.set("image", lua.create_function(register_image)?)?;
        table.into_lua(lua)
    }
}
```

Add the callback:

```rust
fn register_image(lua: &Lua, (name,): (String,)) -> mlua::Result<()> {
    let r = lua
        .app_data_ref::<Registration>()
        .ok_or_else(|| mlua::Error::external("quire.ci registration not installed on Lua VM"))?;
    let line = lua
        .inspect_stack(1, |d| d.current_line())
        .flatten()
        .map(|l| l as u32)
        .unwrap_or(0);
    let mut image = r.image.borrow_mut();
    match &*image {
        Some(_) => {
            let span = super::pipeline::span_for_line(&r.source, line);
            r.jobs
                .borrow_mut()
                .push(Err(ValidationError::DuplicateImage { span }));
        }
        None => {
            *image = Some(ImageRegistration { name, line });
        }
    }
    Ok(())
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cargo test --lib ci::pipeline::tests::load_registers_pipeline_image -- --nocapture`
Run: `cargo test --lib ci::pipeline::tests::load_succeeds_without_image -- --nocapture`
Expected: PASS

- [ ] **Step 5: Commit**

```
Add ci.image function to register pipeline container image

Extends the quire.ci Lua module with an `image` function that
stores the container image name on the Pipeline struct. The image
is exposed via `Pipeline::image()` for the executor to read at
job spawn time. Pipelines without an image declaration load
successfully — the executor resolves a default at run time.
```

---

### Task 3: Reject duplicate `ci.image` calls

**Files:**
- Modify: `src/ci/pipeline.rs` — test

- [ ] **Step 1: Write the failing test**

Add to `src/ci/pipeline.rs` tests:

```rust
#[test]
fn load_errors_on_duplicate_image() {
    let source = r#"(local ci (require :quire.ci))
(ci.image "alpine")
(ci.image "ubuntu")
(ci.job :build [:quire/push] (fn [_] nil))"#;
    let result = Pipeline::load(source, "ci.fnl");
    assert!(result.is_err(), "duplicate image should fail");
    let err = result.unwrap_err();
    let msg = err.to_string();
    assert!(msg.contains("CI validation failed"), "expected validation error: {msg}");
}
```

- [ ] **Step 2: Run test to verify it passes**

The `register_image` callback from Task 2 already pushes `DuplicateImage` on the second call, so this should pass immediately. If it does, this test documents the invariant.

Run: `cargo test --lib ci::pipeline::tests::load_errors_on_duplicate_image -- --nocapture`
Expected: PASS

- [ ] **Step 3: Commit**

```
Test that duplicate ci.image declarations produce a validation error
```

---

### Task 4: Error when `ci.image` is called inside a run-fn

**Files:**
- Modify: `src/ci/run.rs` — add test
- No production changes needed

- [ ] **Step 1: Write the test**

The `quire.ci` module is cached by `require`. During execution, the `Registration` app data has been replaced by `Rc<Runtime>`, so calling `ci.image` inside a run-fn triggers the "registration not installed" error. This test locks in that behavior.

Add to `src/ci/run.rs` tests:

```rust
#[test]
fn execute_errors_when_image_called_in_run_fn() {
    let (_dir, quire) = tmp_quire();
    let runs = test_runs(&quire);
    let run = runs.create(&test_meta()).expect("create");

    let pipeline = load(
        r#"(local ci (require :quire.ci))
(ci.image "alpine")
(ci.job :bad [:quire/push]
  (fn [_]
    (ci.image "sneaky")))"#,
    );

    let err = run
        .execute(pipeline, HashMap::new(), std::path::Path::new("."))
        .expect_err("expected failure");
    let Error::JobFailed { job, source } = err else {
        unreachable!()
    };
    assert_eq!(job, "bad");
    let msg = source.to_string();
    assert!(
        msg.contains("registration not installed"),
        "expected registration error, got: {msg}"
    );
}
```

- [ ] **Step 2: Run test to verify it passes**

Run: `cargo test --lib ci::run::tests::execute_errors_when_image_called_in_run_fn -- --nocapture`
Expected: PASS — naturally enforced by the app-data swap

- [ ] **Step 3: Commit**

```
Test that ci.image errors when called inside a run-fn
```

---

### Task 5: Fix existing tests broken by API changes

**Files:**
- Modify: `src/ci/pipeline.rs` — update `parse_results`/`parsed_jobs` helpers for `ParseOutput`
- Modify: `src/ci/mod.rs` — no changes needed (tests don't use `ParseOutput` directly)
- Modify: `src/ci/run.rs` — no changes needed (tests don't use `ParseOutput` directly)

- [ ] **Step 1: Run the full test suite**

Run: `cargo test --lib`
Expected: Failures in tests that use `parse_results`/`parsed_jobs` helpers, since `lua::parse` now returns `ParseOutput` instead of `Vec<Result<Job, ValidationError>>`

- [ ] **Step 2: Update helpers in `pipeline.rs` tests**

The `parse_results` helper currently calls `lua::parse` and returns the vec. Update it to unwrap `.jobs`:

```rust
fn parse_results(source: &str) -> Vec<std::result::Result<Job, ValidationError>> {
    let f = Fennel::new().expect("Fennel::new() should succeed");
    lua::parse(&f, source, "ci.fnl").expect("parse should succeed").jobs
}
```

- [ ] **Step 3: Run full test suite**

Run: `cargo test --lib`
Expected: All tests pass

- [ ] **Step 4: Commit**

```
Update test helpers for ParseOutput return type
```

---

### Task 6: Run full check suite and verify

**Files:**
- No changes expected

- [ ] **Step 1: Run `just all`**

Run: `just all`
Expected: Everything passes — fmt, clippy, test

- [ ] **Step 2: Mark task done**

```
ranger task edit vo --state done
```