Skip to main content

speccoder_runtime/
planning_chain.rs

1//! Plan through: one description, planned by the format's own skills in
2//! the order its [`Planning`](crate::format::Planning) gives — the
3//! creating skill, then toward the task list, then the check — each run
4//! unchanged through [`hosting::run_in`](crate::hosting::run_in). It stops
5//! only where a person decides: open questions after the spec is written,
6//! the person's review of the spec before anything is planned, a failure,
7//! or the end of what was asked for.
8//!
9//! It holds no state of its own. Where it stopped is read again from the
10//! unit's files and the review record; the continuation is the same
11//! action from the next skill ([`resume`]).
12
13use std::path::Path;
14use std::sync::Arc;
15
16use agentplane::model::ModelProvider;
17use speccoder_core::actions::{FeatureSource, Through};
18use speccoder_core::similar::{self, Requirement, SimilarReport};
19use speccoder_core::{
20    ChainStep, ChainStop, CommandData, CommandResult, CommandStatus, PlanCheck,
21    PlanningChainReport, Readiness,
22};
23
24use crate::RunOptions;
25
26const COMMAND: &str = "spec";
27
28/// The format's chain at `root`: its creating skill, the skills toward
29/// the task list after it, and its check.
30struct Chain {
31    prefix: &'static str,
32    creates: Option<&'static str>,
33    toward: fn(bool) -> &'static [&'static str],
34    checks: Option<&'static str>,
35}
36
37impl Chain {
38    fn at(root: &Path) -> Self {
39        let p = crate::format::at(root).planning();
40        Self {
41            prefix: p.prefix,
42            creates: p.creates.first().copied(),
43            toward: p.toward_tasks,
44            checks: p.checks,
45        }
46    }
47
48    fn full(&self, short: &str) -> String {
49        format!("{}{short}", self.prefix)
50    }
51
52    /// The skills that remain after the spec, for `through`, given whether
53    /// the unit has a plan and a task list.
54    fn remaining(&self, through: Through, has_plan: bool, has_tasks: bool) -> Vec<&'static str> {
55        let creates = self.creates;
56        let toward: Vec<&'static str> = (self.toward)(has_plan)
57            .iter()
58            .copied()
59            .filter(|s| Some(*s) != creates)
60            .collect();
61        let mut out: Vec<&'static str> = match through {
62            Through::Spec => Vec::new(),
63            // The plan only: every skill toward the tasks but the last, when
64            // the format has a separate plan step.
65            Through::Plan => {
66                let all: Vec<&'static str> = (self.toward)(false)
67                    .iter()
68                    .copied()
69                    .filter(|s| Some(*s) != creates)
70                    .collect();
71                if all.len() > 1 && !has_plan {
72                    all[..all.len() - 1].to_vec()
73                } else {
74                    Vec::new()
75                }
76            }
77            Through::Tasks | Through::Analyze if has_tasks => Vec::new(),
78            Through::Tasks | Through::Analyze => toward,
79        };
80        if through == Through::Analyze {
81            out.extend(self.checks);
82        }
83        out
84    }
85
86    /// Whether `short` is one of the chain's skills.
87    fn has(&self, short: &str) -> bool {
88        self.creates == Some(short)
89            || (self.toward)(false).contains(&short)
90            || (self.toward)(true).contains(&short)
91            || self.checks == Some(short)
92    }
93
94    /// The chain's skills, installed names, for a usage error.
95    fn names(&self) -> Vec<String> {
96        let mut v: Vec<String> = Vec::new();
97        for s in self
98            .creates
99            .into_iter()
100            .chain((self.toward)(false).iter().copied())
101            .chain(self.checks)
102        {
103            let f = self.full(s);
104            if !v.contains(&f) {
105                v.push(f);
106            }
107        }
108        v
109    }
110}
111
112/// Whether `name` (installed or short) is a skill of the format's chain:
113/// only those take `--through`; `Ok(true)` for the creating skill.
114///
115/// # Errors
116///
117/// `name` is not a skill of the chain: the usage error naming them.
118pub fn takes_through(root: &Path, name: &str) -> Result<bool, String> {
119    let chain = Chain::at(root);
120    let short = name.strip_prefix(chain.prefix).unwrap_or(name);
121    if chain.has(short) {
122        Ok(short == chain.creates.unwrap_or_default())
123    } else {
124        Err(format!(
125            "--through plans along the format's chain: {} — not `{name}`",
126            chain.names().join(", ")
127        ))
128    }
129}
130
131/// The requirement text after its identifier (`- **FR-004**: Users …` →
132/// `Users …`).
133fn requirement_text(id: &str, line: &str) -> String {
134    let after = line.split_once(id).map_or(line, |(_, rest)| rest);
135    after
136        .trim_start_matches(|c: char| c == '*' || c == ':' || c.is_whitespace())
137        .trim()
138        .to_owned()
139}
140
141/// The project's requirements: every unit's, the most recently changed
142/// units first, at most [`similar::MAX_UNITS`] of them; and whether that
143/// limit cut any off.
144fn corpus(root: &Path) -> (Vec<Requirement>, bool) {
145    let Ok(opened) = crate::format::open(root, None) else {
146        return (Vec::new(), false);
147    };
148    let adapter = opened.adapter;
149    let base = std::path::PathBuf::from(&opened.snapshot.root);
150    let mut units: Vec<(std::time::SystemTime, String, String)> = opened
151        .snapshot
152        .features
153        .iter()
154        .map(|f| {
155            let changed = std::fs::metadata(base.join(&f.dir))
156                .and_then(|m| m.modified())
157                .unwrap_or(std::time::UNIX_EPOCH);
158            (changed, f.name.clone(), f.dir.clone())
159        })
160        .collect();
161    units.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
162    let limited = units.len() > similar::MAX_UNITS;
163    units.truncate(similar::MAX_UNITS);
164    let mut out = Vec::new();
165    for (_, name, dir) in units {
166        if let Some(file) = adapter.requirements_file() {
167            let text = std::fs::read_to_string(base.join(&dir).join(file)).unwrap_or_default();
168            for (id, line) in adapter.requirements_in(&text) {
169                let at = text
170                    .lines()
171                    .position(|l| l == line || l.trim() == line.trim())
172                    .and_then(|i| u32::try_from(i + 1).ok())
173                    .map(|n| similar::Place {
174                        path: format!("{dir}/{file}"),
175                        line: n,
176                        quote: line.trim().to_owned(),
177                    });
178                out.push(Requirement {
179                    unit: name.clone(),
180                    text: requirement_text(&id, &line),
181                    id,
182                    at,
183                });
184            }
185        } else if let Ok(o) = crate::format::open(root, Some(&name)) {
186            for r in adapter.oracle_requirements(&o.snapshot) {
187                out.push(Requirement {
188                    unit: name.clone(),
189                    id: r.title.clone(),
190                    text: format!("{} {}", r.title, r.text),
191                    at: None,
192                });
193            }
194        }
195    }
196    (out, limited)
197}
198
199/// `speccoder feature similar "…"`: the requirements of the project close
200/// to `text`. Read-only; no model.
201#[must_use]
202pub fn similar_to(root: &Path, text: &str) -> SimilarReport {
203    let (corpus, limited) = corpus(root);
204    SimilarReport {
205        query: text.to_owned(),
206        compared: corpus.len(),
207        limited,
208        matches: similar::matches(text, &corpus),
209    }
210}
211
212/// [`similar_to`] as a command result.
213#[must_use]
214pub fn similar_command(root: &Path, text: &str) -> CommandResult {
215    let report = similar_to(root, text);
216    let message = match report.matches.first() {
217        None => "no requirement is close".to_owned(),
218        Some(m) => format!(
219            "{} close: {} {} ({}, {:.2})",
220            report.matches.len(),
221            m.unit,
222            m.requirement,
223            m.level.word(),
224            m.score
225        ),
226    };
227    CommandResult::new("feature", CommandStatus::Ok, &message)
228        .with_data(CommandData::Similar(Box::new(report)))
229}
230
231/// `speccoder suggest "…"`: the rung the text suggests, with the command
232/// that takes it in this project's format.
233#[must_use]
234pub fn suggest(root: &Path, text: &str) -> CommandResult {
235    let mut s = speccoder_core::ladder::suggest(text);
236    let create = Chain::at(root).creates;
237    s.command = speccoder_core::ladder::command_for(s.rung, text, create);
238    let message = if s.rung == speccoder_core::ladder::RungPick::None {
239        s.reason.clone()
240    } else {
241        format!("{} — {}", s.rung.label(), s.reason)
242    };
243    let next: Vec<speccoder_core::next::NextStep> = s
244        .command
245        .iter()
246        .map(|c| speccoder_core::next::NextStep {
247            command: c.clone(),
248            why: format!("take the {} rung", s.rung.label().to_lowercase()),
249        })
250        .collect();
251    CommandResult::new("suggest", CommandStatus::Ok, &message)
252        .with_data(CommandData::RungSuggestion(Box::new(s)))
253        .with_next(next)
254}
255
256/// The unit's directory name, and whether it has a plan and a task list.
257fn unit_facts(root: &Path, feature: Option<&str>) -> Option<(String, bool, bool)> {
258    let opened = crate::format::open(root, feature).ok()?;
259    let f = opened.snapshot.feature.as_ref()?;
260    Some((f.feature.name.clone(), f.artifacts.plan, f.artifacts.tasks))
261}
262
263/// The plan checks of the unit's task list (spec 102), as the stop carries them.
264fn plan_checks(root: &Path, unit: &str) -> Vec<PlanCheck> {
265    let Ok(o) = crate::format::open(root, Some(unit)) else {
266        return Vec::new();
267    };
268    if o.snapshot
269        .feature
270        .as_ref()
271        .is_none_or(|f| f.tasks.is_none())
272    {
273        return Vec::new();
274    }
275    crate::plan_lint::lints(o.adapter, &o.snapshot, root)
276        .into_iter()
277        .map(|l| PlanCheck {
278            code: l.code.to_owned(),
279            tasks: l.tasks,
280            detail: l.detail,
281        })
282        .collect()
283}
284
285fn step_of(result: &CommandResult, skill: &str) -> ChainStep {
286    let run_id = match result.data() {
287        Some(CommandData::Skill(r)) => Some(r.run_id.clone()).filter(|r| !r.is_empty()),
288        _ => None,
289    };
290    ChainStep {
291        skill: skill.to_owned(),
292        status: result.status(),
293        message: result.message().to_owned(),
294        run_id,
295    }
296}
297
298/// What the person must decide before planning, read from the unit's
299/// files and the review record: the open questions, else the requirements
300/// not yet approved on their current wording (`None`: nothing).
301fn gate(root: &Path, unit: &str) -> Option<ChainStop> {
302    let review = crate::review::summary(root, Some(unit))?;
303    if !review.questions.is_empty() {
304        return Some(ChainStop::Questions {
305            questions: review.questions.iter().map(|q| q.id.clone()).collect(),
306        });
307    }
308    (!review.totals.complete() && !review.requirements.is_empty()).then(|| ChainStop::Review {
309        requirements: review.requirements.iter().map(|r| r.id.clone()).collect(),
310    })
311}
312
313/// The result of a stop: its message, status and code, worded for the
314/// unit.
315fn finish(root: &Path, mut report: PlanningChainReport, options: &RunOptions) -> CommandResult {
316    let unit = report.unit.clone();
317    report.readiness = unit
318        .as_deref()
319        .and_then(|u| crate::review::readiness(root, Some(u)));
320    let chain = Chain::at(root);
321    let facts = unit.as_deref().and_then(|u| unit_facts(root, Some(u)));
322    let (has_plan, has_tasks) = facts.as_ref().map_or((false, false), |f| (f.1, f.2));
323    report.next_skill = match report.stop {
324        ChainStop::Done { .. } => None,
325        ChainStop::Failed { ref skill, .. } => Some(skill.clone()),
326        ChainStop::Questions { .. } | ChainStop::Review { .. } => chain
327            .remaining(report.through, has_plan, has_tasks)
328            .first()
329            .map(|s| chain.full(s)),
330    };
331    if let Some(p) = &options.progress {
332        p.event(&crate::progress::Event::PlanningStopped {
333            unit: unit.clone(),
334            kind: report.stop.as_str().to_owned(),
335            next_skill: report.next_skill.clone(),
336        });
337    }
338    let unit_name = unit.clone().unwrap_or_default();
339    let (status, code, message) = match &report.stop {
340        ChainStop::Questions { questions } => (
341            CommandStatus::NeedsHuman,
342            Some("THROUGH-QUESTIONS"),
343            format!(
344                "{} open question{} in {unit_name}: answer {}, then review the spec",
345                questions.len(),
346                if questions.len() == 1 { "" } else { "s" },
347                if questions.len() == 1 { "it" } else { "them" }
348            ),
349        ),
350        ChainStop::Review { requirements } => (
351            CommandStatus::NeedsHuman,
352            Some("THROUGH-REVIEW"),
353            format!(
354                "review the spec — {} requirement{}; approve them to plan",
355                requirements.len(),
356                if requirements.len() == 1 { "" } else { "s" }
357            ),
358        ),
359        ChainStop::Failed { skill, message, .. } => (
360            CommandStatus::RunFailed,
361            None,
362            format!("{skill} failed: {message}"),
363        ),
364        ChainStop::Done { plan_checks } => {
365            let steps = ["spec", "plan", "tasks"]
366                .iter()
367                .filter(|s| match **s {
368                    "spec" => true,
369                    "plan" => has_plan,
370                    _ => has_tasks,
371                })
372                .map(|s| format!("{s} ✓"))
373                .collect::<Vec<_>>()
374                .join(" ");
375            let checks = if plan_checks.is_empty() {
376                String::new()
377            } else {
378                format!(
379                    "; {} plan check{} to look at",
380                    plan_checks.len(),
381                    if plan_checks.len() == 1 { "" } else { "s" }
382                )
383            };
384            (
385                CommandStatus::Ok,
386                None,
387                format!(
388                    "{steps} in {unit_name}{checks} — planning files are left for you to commit"
389                ),
390            )
391        }
392    };
393    let next = report.next_skill.clone();
394    let through = report.through;
395    let mut result = CommandResult::new(COMMAND, status, &message)
396        .with_data(CommandData::PlanningChain(Box::new(report)));
397    if let Some(code) = code {
398        result = result.with_code(code);
399        let mut fix = Vec::new();
400        if code == "THROUGH-QUESTIONS" {
401            fix.push("speccoder review".to_owned());
402        } else {
403            fix.push(format!("speccoder review approve --feature {unit_name}"));
404        }
405        if let Some(n) = next {
406            let short = n.strip_prefix(chain.prefix).unwrap_or(&n).to_owned();
407            fix.push(format!(
408                "speccoder spec {short} --through {} --feature {unit_name}",
409                through.as_str()
410            ));
411        }
412        result = result.with_fix(fix);
413    }
414    result
415}
416
417fn report(through: Through, source: FeatureSource) -> PlanningChainReport {
418    PlanningChainReport {
419        unit: None,
420        through,
421        ran: Vec::new(),
422        stop: ChainStop::Done {
423            plan_checks: Vec::new(),
424        },
425        readiness: None,
426        similar: Vec::new(),
427        source,
428        next_skill: None,
429    }
430}
431
432/// Runs the skills of `todo` one by one; stops at the first failure.
433#[allow(clippy::too_many_arguments)]
434fn run_steps(
435    root: &Path,
436    unit: &str,
437    todo: &[&'static str],
438    chain: &Chain,
439    state: &Path,
440    provider: &Arc<dyn ModelProvider>,
441    secret: Option<&str>,
442    options: &RunOptions,
443    r: &mut PlanningChainReport,
444) -> bool {
445    for short in todo {
446        let name = chain.full(short);
447        let result = crate::hosting::run_in(
448            root,
449            Some(unit),
450            &name,
451            "",
452            state,
453            Arc::clone(provider),
454            secret,
455            options,
456        );
457        r.ran.push(step_of(&result, &name));
458        if result.status() != CommandStatus::Ok {
459            r.stop = ChainStop::Failed {
460                skill: name,
461                message: result.message().to_owned(),
462                code: result.code().map(|c| c.name.to_owned()),
463            };
464            return false;
465        }
466    }
467    true
468}
469
470/// The `issue` origin of the new unit, written once.
471fn record_issue(root: &Path, state: &Path, unit: &str, number: u64) {
472    let repo = crate::github::repo(root, None).ok().map(|r| r.slug());
473    let plane = crate::worktree::project_state_dir(state, root);
474    let _ = crate::ladder::record_origin(
475        &plane,
476        unit,
477        vec![speccoder_core::ladder::Origin::Issue {
478            number,
479            repo,
480            context_sha256: None,
481        }],
482    );
483}
484
485/// Plan through from a description, against `state` with `provider`.
486#[allow(clippy::too_many_arguments)]
487#[must_use]
488pub fn start_with(
489    root: &Path,
490    description: &str,
491    source: &FeatureSource,
492    through: Through,
493    state: &Path,
494    provider: &Arc<dyn ModelProvider>,
495    secret: Option<&str>,
496    options: &RunOptions,
497) -> CommandResult {
498    let description = description.trim();
499    if description.is_empty() {
500        return CommandResult::new("job", CommandStatus::UsageError, "a description is needed");
501    }
502    let chain = Chain::at(root);
503    let Some(creates) = chain.creates else {
504        let format = crate::format::at(root);
505        return CommandResult::new(
506            COMMAND,
507            CommandStatus::UsageError,
508            &format!(
509                "{} has no skill that creates a {}: {}",
510                format.wording().name,
511                format.messages().unit,
512                format.messages().make_project.1
513            ),
514        );
515    };
516    let mut r = report(through, source.clone());
517    r.similar = similar_to(root, description).matches;
518    if let (Some(m), Some(p)) = (r.similar.first(), &options.progress) {
519        p.event(&crate::progress::Event::Notice {
520            text: format!(
521                "warning[SIMILAR-FEATURE]: close to {} {} \"{}\" — to extend it instead: speccoder feature {} && speccoder review comments",
522                m.unit, m.requirement, m.text, m.unit
523            ),
524        });
525    }
526    let name = chain.full(creates);
527    let made = crate::hosting::run_in(
528        root,
529        None,
530        &name,
531        description,
532        state,
533        Arc::clone(provider),
534        secret,
535        options,
536    );
537    r.ran.push(step_of(&made, &name));
538    if made.status() != CommandStatus::Ok {
539        r.stop = ChainStop::Failed {
540            skill: name,
541            message: made.message().to_owned(),
542            code: made.code().map(|c| c.name.to_owned()),
543        };
544        return finish(root, r, options);
545    }
546    let Some((unit, has_plan, has_tasks)) = unit_facts(root, None) else {
547        r.stop = ChainStop::Failed {
548            skill: name,
549            message: "the skill ended without a unit speccoder can find".to_owned(),
550            code: None,
551        };
552        return finish(root, r, options);
553    };
554    if let FeatureSource::Issue { number } = source {
555        record_issue(root, state, &unit, *number);
556    }
557    r.unit = Some(unit.clone());
558    r.stop = match gate(root, &unit) {
559        Some(q @ ChainStop::Questions { .. }) => q,
560        Some(review) if through != Through::Spec => review,
561        _ if through == Through::Spec => ChainStop::Done {
562            plan_checks: Vec::new(),
563        },
564        // Nothing to approve (no requirement ids): planning still waits
565        // for the person, unless nothing is left to run.
566        _ if chain.remaining(through, has_plan, has_tasks).is_empty() => ChainStop::Done {
567            plan_checks: plan_checks(root, &unit),
568        },
569        _ => ChainStop::Review {
570            requirements: Vec::new(),
571        },
572    };
573    finish(root, r, options)
574}
575
576/// Plan through from a description with the configured model.
577#[must_use]
578pub fn start(
579    root: &Path,
580    description: &str,
581    source: &FeatureSource,
582    through: Through,
583    options: &RunOptions,
584) -> CommandResult {
585    if description.trim().is_empty() {
586        return CommandResult::new("job", CommandStatus::UsageError, "a description is needed");
587    }
588    crate::with_driver(COMMAND, root, None, |state, provider, key| {
589        start_with(
590            root,
591            description,
592            source,
593            through,
594            state,
595            provider,
596            Some(key),
597            options,
598        )
599    })
600}
601
602/// The continuation after a stop: the next skills of the chain, derived
603/// from the unit's files — refused while a question is open or a
604/// requirement is not approved on its current wording.
605#[allow(clippy::too_many_arguments)]
606#[must_use]
607pub fn resume_with(
608    root: &Path,
609    feature: Option<&str>,
610    through: Through,
611    state: &Path,
612    provider: &Arc<dyn ModelProvider>,
613    secret: Option<&str>,
614    options: &RunOptions,
615) -> CommandResult {
616    let chain = Chain::at(root);
617    let Some((unit, has_plan, has_tasks)) = unit_facts(root, feature) else {
618        return CommandResult::new(
619            COMMAND,
620            CommandStatus::SelectionNeeded,
621            "no feature to plan: start one with a description",
622        );
623    };
624    let mut r = report(through, FeatureSource::Description);
625    r.unit = Some(unit.clone());
626    if let Some(stop) = gate(root, &unit) {
627        r.stop = stop;
628        return finish(root, r, options);
629    }
630    let todo = chain.remaining(through, has_plan, has_tasks);
631    if run_steps(
632        root, &unit, &todo, &chain, state, provider, secret, options, &mut r,
633    ) {
634        r.stop = ChainStop::Done {
635            plan_checks: plan_checks(root, &unit),
636        };
637    }
638    finish(root, r, options)
639}
640
641/// The continuation with the configured model.
642#[must_use]
643pub fn resume(
644    root: &Path,
645    feature: Option<&str>,
646    through: Through,
647    options: &RunOptions,
648) -> CommandResult {
649    // Refused before any model is opened: the gate needs none.
650    if let Some((unit, _, _)) = unit_facts(root, feature)
651        && let Some(stop) = gate(root, &unit)
652    {
653        let mut r = report(through, FeatureSource::Description);
654        r.unit = Some(unit);
655        r.stop = stop;
656        return finish(root, r, options);
657    }
658    crate::with_driver(COMMAND, root, feature, |state, provider, key| {
659        resume_with(root, feature, through, state, provider, Some(key), options)
660    })
661}
662
663/// The readiness of the unit (a re-export for the page's strip).
664#[must_use]
665pub fn readiness(root: &Path, feature: Option<&str>) -> Option<Readiness> {
666    crate::review::readiness(root, feature)
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    fn speckit() -> Chain {
674        Chain {
675            prefix: "speckit-",
676            creates: Some("specify"),
677            toward: |plan| if plan { &["tasks"] } else { &["plan", "tasks"] },
678            checks: Some("analyze"),
679        }
680    }
681
682    fn openspec() -> Chain {
683        Chain {
684            prefix: "openspec-",
685            creates: Some("propose"),
686            toward: |_| &["propose"],
687            checks: None,
688        }
689    }
690
691    #[test]
692    fn the_remaining_skills_follow_the_format_and_the_files() {
693        let s = speckit();
694        assert_eq!(s.remaining(Through::Spec, false, false), Vec::<&str>::new());
695        assert_eq!(s.remaining(Through::Plan, false, false), vec!["plan"]);
696        assert_eq!(s.remaining(Through::Plan, true, false), Vec::<&str>::new());
697        assert_eq!(
698            s.remaining(Through::Tasks, false, false),
699            vec!["plan", "tasks"]
700        );
701        assert_eq!(s.remaining(Through::Tasks, true, false), vec!["tasks"]);
702        assert_eq!(s.remaining(Through::Tasks, true, true), Vec::<&str>::new());
703        assert_eq!(s.remaining(Through::Analyze, true, true), vec!["analyze"]);
704        assert_eq!(
705            s.remaining(Through::Analyze, false, false),
706            vec!["plan", "tasks", "analyze"]
707        );
708        let o = openspec();
709        assert_eq!(
710            o.remaining(Through::Tasks, false, false),
711            Vec::<&str>::new()
712        );
713        assert_eq!(
714            o.remaining(Through::Analyze, false, false),
715            Vec::<&str>::new()
716        );
717    }
718
719    #[test]
720    fn only_the_chain_takes_through() {
721        let s = speckit();
722        assert!(s.has("specify") && s.has("plan") && s.has("tasks") && s.has("analyze"));
723        assert!(!s.has("clarify") && !s.has("checklist"));
724        assert_eq!(
725            s.names(),
726            vec![
727                "speckit-specify",
728                "speckit-plan",
729                "speckit-tasks",
730                "speckit-analyze"
731            ]
732        );
733    }
734
735    #[test]
736    fn a_requirement_line_loses_its_identifier() {
737        assert_eq!(
738            requirement_text("FR-004", "- **FR-004**: Users can export a report as PDF."),
739            "Users can export a report as PDF."
740        );
741        assert_eq!(requirement_text("FR-1", "nothing"), "nothing");
742    }
743}