diff --git a/CLAUDE.md b/CLAUDE.md index 264278b6c..dbcb253b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,8 @@ This applies even at the end of sessions. Prepare the commit but wait for approv When asked to 'stage and commit everything' or 'commit all changes', stage ALL modified/untracked files (`git add -A`), not just the files Claude edited in the current session. +**Commit-and-continue during approved plan execution:** when executing a plan the user has already approved, commit at each clean phase boundary (pre-commit checklist in `claude-notes/instructions/review.md` passed, full workspace tests green) without stopping to ask, and report the commit in the running summary. Waiting for approval is only required for commits outside approved plan execution, for dirty states, and always for pushing. + ### Snapshot Test Changes When a commit includes updated or new snapshot files (`.snap` files under `snapshots/`), **always explicitly document these changes** in the commit message and in conversation with the user. Snapshot changes can hide unwanted regressions. Specifically: diff --git a/claude-notes/instructions/review.md b/claude-notes/instructions/review.md index 14ae91f0d..8f49884e9 100644 --- a/claude-notes/instructions/review.md +++ b/claude-notes/instructions/review.md @@ -2,7 +2,12 @@ **Read this file and complete the checklist before making any commit. Do not skip items.** -**When checklist is finished, stage your changes, report results to user and wait for approval before making the final commit.** +**When checklist is finished, stage your changes and report results to the user.** Whether to then commit without waiting depends on the mode of work: + +- **Plan-driven execution the user has already approved** (a plan document with phases, user said "go ahead"): **commit-and-continue at clean phase boundaries.** A phase boundary is clean when the checklist passes and the full workspace test suite is green. Report the commit in the running summary; do not stop to ask. (Policy set 2026-08-10, project-profiles session.) +- **Anything else** (ad-hoc changes, work outside an approved plan, a phase that ended dirty — failing tests, skipped items, surprising snapshot diffs): report and **wait for approval before committing**. + +Pushing always requires explicit approval regardless of mode (see CLAUDE.md). ## Determinism diff --git a/claude-notes/plans/2026-08-10-project-profiles-port.md b/claude-notes/plans/2026-08-10-project-profiles-port.md new file mode 100644 index 000000000..8271ce5a4 --- /dev/null +++ b/claude-notes/plans/2026-08-10-project-profiles-port.md @@ -0,0 +1,436 @@ +# Project profiles: port from Quarto 1 (bd-fu16z22k) + +**Strand:** bd-fu16z22k (related: bd-ev8mk1rp, bd-mlj6) +**Status:** plan under iteration — not yet approved for execution +**Session goal (secondary):** this session doubles as a prototype for a +"feature porting" skill; after execution we will write up the process. + +## Overview + +Port Quarto 1's *project profiles* to Q2: activation via `--profile` / +`QUARTO_PROFILE`, `profile.default` + `profile.group` config, profile +config overlays (`_quarto-.yml`), local config overrides +(`_quarto.yml.local`), profile-specific environment files +(`_environment-`, seam left by PR #486), and conditional content +(`when-profile` — greenfield in Q2, built as the full +format/profile/meta trio). Q1 guesses silently in many corner cases; +Q2 will validate strictly with span-carrying diagnostics. + +### ⚠️ Terminology + +Q2 already uses "profile" for **`DocumentProfile`** — the pass-1 +document summary and its cache (`document_profile.rs`, +`profile_cache.rs`, `PROFILE_KEY_VERSION`, `--clean-cache`). This +feature is **"project profiles"**. In code: `active_config_profiles`, +`ProjectProfileConfig`, `profile_config_paths` — never a bare +`profiles` field, and never reuse the `"profiles"` cache namespace. + +## Q1 reference (what we're porting) + +Q1 sources: `src/quarto-core/profile.ts`, +`src/project/project-profile.ts`, `src/quarto-core/dotenv.ts`, +schema `resources/schema/definitions.yml` (`project-profile`, closed +object: `default: maybeArrayOf string`, `group: maybeArrayOf (arrayOf +string)`). Docs: `quarto-web/docs/projects/profiles.qmd`. + +Activation (first non-empty source wins): +1. `--profile` CLI (in Q1 literally overwrites `QUARTO_PROFILE`; *replaces*, never merges) +2. `QUARTO_PROFILE` process env var +3. `QUARTO_PROFILE` key read from `_environment.local`, then `_environment` (never from `_environment-

`) +4. `profile.default` in `_quarto.yml.local` +5. `profile.default` in `_quarto.yml` +6. (dropped in Q2: `RSTUDIO_PRODUCT=CONNECT` → `connect`) + +Then group expansion: for each group in `profile.group` (read from +`_quarto.yml` only), if no member is active, append the group's +**first** member. The normalized list becomes the canonical active +set, visible to subprocesses as `QUARTO_PROFILE`. + +Config merge order (lowest → highest): `_quarto.yml` → active +profiles' `_quarto-

.yml` in **reverse activation order** +(first-listed profile wins) → `_quarto.yml.local`. `.yml` preferred +over `.yaml`. Project root only; no profile variant of +`_metadata.yml`. The `profile:` key is read from the base config then +stripped before merging. + +Separators in the profile string: `/[ ,]+/` (comma and/or space; +colons are NOT separators). + +### Q1 → Q2 divergence table (deliberate) + +| Behavior | Q1 | Q2 (this port) | +|---|---|---| +| `--profile` implementation | mutates process env (`Deno.env.set`) | data plumbed through `ProjectContext`; process env never mutated (policy from PR #486) | +| Unknown active profile | silent | **Q-5-19 warning** (silenced by declaring the name in `profile.default`/`group`) | +| Empty names from `" a,b"` | `["", "a", "b"]` pollutes list | trimmed; empty segments dropped; fully-empty selection = error | +| Mixed-shape `group` (`[a, [b,c]]`) | silently zero groups | **error** with span | +| Unknown keys under `profile:` | schema error (Q1 has schema) | **error** (closed-object check; one of the few places Q2 validates shape since there's no schema layer) | +| `profile:` inside `_quarto-

.yml` | silently inert (merged back in, ignored) | **warning** (inert + stripped) | +| Non-string `profile.default` entries | `String()` coercion (base) / uncoerced (.local) | **error** with span | +| Array merge in overlays | union-concat with dedup | Q2 `MergeOp::Concat` (append, no dedup); users control via `!prefer`/`!concat` tags | +| `metadata-files` inside profiles | documented as unresolved (wart) | moot for now — Q2 has no `metadata-files`; whether to add the feature at all is bd-spb7mobo (if ported, fix the wart rather than replicate it) | +| Connect auto-detect | yes (undocumented) | dropped; revisit with Connect support | +| Preview on profile change | terminates itself | config read at session start; restart to pick up (matches PR #486 env-file decision); document it | +| Active profile echo | deliberately not printed | printed at `-v` only; normal output stays quiet | +| Profile name charset | anything (incl. empty strings, path chars) | strict: `[A-Za-z0-9][A-Za-z0-9._-]*`, else Q-5-21 error | + +### Decisions locked with Carlos (2026-08-10) + +- Conditional content: build the **full trio** (`when-`/`unless-` × + `format`/`profile`/`meta` on `.content-visible`/`.content-hidden` + divs and spans) as the final phase. +- Multi-profile precedence: **first-listed wins** (Q1 parity; + consistent with PR #486's `_environment-

` layer order). +- Unknown active profile: **warning**, not silent, not fatal. +- Port `QUARTO_PROFILE`-from-`_environment` bootstrap: **yes**. +- Port `_quarto.yml.local`: **yes** (both roles: `profile.default` + source and highest-priority merge layer). +- Connect auto-detect: **no**. +- Profile-name charset: **strict, error** (Q-5-21). Rule: + `[A-Za-z0-9][A-Za-z0-9._-]*` — filename-safe, no leading `.`, no + internal whitespace. Applies to names from every source (CLI, env, + `profile.default`, `profile.group`). +- Empty `--profile` value: folded into Q-5-21 (it is an error code). +- Resolved active set echoed **at `-v` only**; normal output stays + quiet (Q1 parity). +- `when-meta`: port Q1's **dotted-path lookup + truthiness** of the + resolved metadata value. + +## Architecture + +### Where resolution happens + +Everything lands in `ProjectContext::parse_config` +(`crates/quarto-core/src/project/mod.rs:1321`), between +`yaml_to_config_value` (~line 1340) and `resolve_project_type`: + +1. Parse base `_quarto.yml` → `ConfigValue` (existing). +2. Extract + strip `profile:` → typed `ProjectProfileConfig + { default: Vec, group: Vec> }` (strict + validation here). +3. Parse `_quarto.yml.local` (if present) early to get its + `profile.default`; hold its `ConfigValue` for the merge. +4. Resolve activation (see order above; env via `runtime.env_get`, + dotenv bootstrap via a minimal read of `_environment{,.local}` + once PR #486's parser is on main). +5. Read each active profile's `_quarto-

.yml`/`.yaml` → + `ConfigValue` (strip+warn `profile:` keys). +6. Merge with `quarto-config`'s `MergedConfig`: layers lowest-first = + `[base, profiles in reverse activation order, local]`; + `materialize()` result becomes the `metadata` that steps 5–7 of + `parse_config` already consume. **`MetadataMergeStage` needs zero + changes** — `project.type`, `output-dir`, `render`, `resources`, + pre/post-render, and `brand` become profile-aware for free. + +Because the merged `ConfigValue` carries `SourceInfo` from the overlay +files, span integrity depends on registering those files everywhere +`_quarto.yml` is registered today (see "Source tracking"). + +### Profile selection input (no env mutation) + +- `ProjectContext::discover(path, runtime)` keeps its signature and + resolves activation itself (reading `QUARTO_PROFILE` through + `runtime.env_get` — WASM/hub runtimes return nothing → profiles + simply inactive there, which is correct for now). +- New `ProjectContext::discover_with_profile(path, runtime, + Option<&[String]>)` (name TBD) carries an explicit CLI selection; + `discover` delegates with `None`. CLI passes `--profile` values; + `Some` replaces the env var entirely (Q1 semantics). All ~15 + existing `discover` call sites stay source-compatible. +- Resolved state stored on `ProjectConfig`: + `active_config_profiles: Vec` (normalized, activation + order) and `profile_config_paths: Vec` (overlay + local + files actually read, for source binding / cache keys / preview). +- `render.rs` re-discovery after pre-render scripts (`render.rs:885`) + passes the same explicit selection. + +### CLI + +`--profile` already exists on `q2 render` (`main.rs:143`, +`Vec`) but is dropped by the `..` destructure at +`main.rs:762`. Wire it into `RenderArgs` and thread to every +`discover` call in `commands/render.rs` (`:280/:338/:423/:746/:827/:885`). +Accept both repeated flags and comma/space-separated values +(Q1-compatible split `[ ,]+`, then trim + drop empties). Add the same +flag to `preview`, `get-config`, and `publish` (all go through +`discover`). `q2 get-config` becomes the introspection surface — +"which config am I getting under `--profile x`" works for free. + +### Subprocess environment (`QUARTO_PROFILE` for user code) + +Q1 promise: engine code (Python/R) can read `QUARTO_PROFILE`. With +the no-mutation policy, the normalized active list is applied to +**child** processes via `Command::env` (engine subprocesses, pre/post +render scripts — the exact mechanism PR #486 built for project env +pairs). Special case vs #486's "real env always wins" filter: +`QUARTO_PROFILE` must be set on children **unconditionally** — if +`--profile b` overrode an inherited `QUARTO_PROFILE=a`, children must +see `b` (Q1 parity, where the env var was overwritten). Also insert +the normalized value into the project env map so `{{< env +QUARTO_PROFILE >}}` resolves. + +### Source tracking / diagnostics plumbing + +- Register overlay + local files with + `quarto_yaml::file_id_for_filename` path-spelling discipline. +- Extend `bind_config_source` candidate lists: the convention is + `config_path` + `extension_manifest_paths` + (`commands/render.rs:752-757`); add `profile_config_paths`. +- Extend `MetadataMergeStage`'s `register` closure + (`metadata_merge.rs:298-328`) so profile-file FileIds referenced by + merged-config SourceInfos resolve in both document source contexts. +- Profile resolution diagnostics go to + `ProjectConfig.config_diagnostics` (printed once per run). + +### New error-catalog codes (subsystem `project`, Q-5-*) + +| Code | Severity | Meaning | +|---|---|---| +| Q-5-19 | warning | Active profile matches nothing (no `_quarto-

.yml`, no `_environment-

`, not declared in `profile.default`/`group`) | +| Q-5-20 | error | Invalid `profile:` config shape (unknown key under `profile:`, mixed-shape `group`, non-string entries) | +| Q-5-21 | error | Invalid profile name. Names must match `[A-Za-z0-9][A-Za-z0-9._-]*` (filename-safe, no leading `.`, no whitespace); also covers empty-after-trim, including an empty `--profile` value. | +| Q-5-22 | warning | `profile:` key inside a profile overlay or `.local` file where it has no effect (in `.local`, only `default` is honored) | + +(Parse failures in overlay files reuse the existing YAML Q-1-* codes; +they surface with the file's own spans.) + +### Cache-key integration (correctness-critical) + +`Pass1KeyInputs` (`cache_key.rs:106`) must gain: the normalized +active-profile list and `(path, bytes)` of every overlay/local file +read — otherwise switching profiles serves stale pass-1 +`DocumentProfile`s. Follow the existing `metadata_files` pattern. +Comment heavily (both meanings of "profile" collide on these lines). + +### Conditional content (final phase; greenfield) + +- Syntax: `.content-visible` / `.content-hidden` on divs **and + spans**, attributes `when-format`, `unless-format`, `when-profile`, + `unless-profile`, `when-meta`, `unless-meta`. +- Semantics (from Q1's `content-hidden.lua`): different condition + kinds AND together; multiple values within one kind OR; + `unless-*` negates; `.content-hidden` with no conditions always + hides; surviving nodes get the attributes stripped. +- Implementation: an AST transform in + `crates/quarto-core/src/transforms/`, **Normalization phase** + (content must disappear before crossref numbering counts it), + format-agnostic registration in `build_transform_pipeline`. Reads: + target format (ctx), active profiles (ProjectContext), merged + metadata (for `when-meta`). +- Sub-decision to settle during the phase: format matching semantics + (`when-format="html"` vs concrete formats like `revealjs` — Q1 has + an alias table in `quarto.format.is_format`; port the alias + groups we already model, document the rest). +- Strictness: unknown `when-*`/`unless-*` attribute spellings on a + content-visible/hidden node → warning (new Q-2-* or Q-5-* code, + decide in-phase). + +### Deferred / out of scope (file follow-up strands at execution end) + +- `quarto.project.profile` Lua API — Q2 has no `quarto.project` + table at all yet; add when one exists. +- Preview hot-reload/watch of profile files (Phase-D hook noted in + `quarto-preview/src/config.rs`); restart is the documented story. +- Auto-gitignore of `/_*.local` on project create (Q1 scaffolding + nicety; Q2 project-create story is separate). +- Connect auto-detection. +- `metadata-file(s):` support in Q2 at all — decision strand + bd-spb7mobo (port-and-fix-the-wart vs. deliberately drop with a + good diagnostic). + +## PR #486 coordination + +Phases 0–2 below do not touch environment files and can proceed on +main now. Phase 3 (env integration) requires #486's +`environment.rs` parser and `StageContext.project_env`; it lands +after #486 merges (rebase, then fill the `&[]` seam at +`stage/context.rs:~259` and implement the `dotenvQuartoProfile` +bootstrap against the real parser). If #486 is delayed, Phase 3 +waits; nothing else blocks. + +## Work items + +### Phase 0 — resolution core (pure logic + tests first) + +- [x] Write failing unit tests for `project_profile` module: + profile-string parsing (`[ ,]+`, trim, empty-drop, colon is + not a separator), activation precedence (CLI replaces env + replaces dotenv replaces local-default replaces base-default), + group expansion (first-member default, append-after-explicit, + multiple groups, flat vs nested list shape), strict shape + errors (Q-5-20/21 cases), first-listed-wins ordering contract + *(41 tests written first, observed failing on stubs, then pass)* +- [x] Implement `crates/quarto-core/src/project/project_profile.rs`: + `ProjectProfileConfig` + `extract_profile_config` (extraction + +strip, site-aware: BaseConfig/LocalConfig/Overlay), + `resolve_active_profiles(inputs, &mut diags) -> + Vec` (name + `ProfileSource` provenance) +- [x] Add Q-5-19..22 entries to + `crates/quarto-error-catalog/error_catalog.json` (docs_url + suffix rule; catalog audit test green) + +### Phase 1 — config overlays + +- [x] Failing integration tests (quarto-core `tests/integration/`): + overlay merge (scalar override, map deep-merge, array concat, + `!prefer`), first-listed-wins with two profiles, + `_quarto.yml.local` over profiles, `.yml` over `.yaml`, + `profile:`-in-overlay warning, unknown-profile warning, + span integrity of a diagnostic pointing into an overlay file + *(25 tests in `project_profile_overlays.rs`, written first, + observed failing on the delegating stub)* +- [x] Implement overlay discovery + merge in `parse_config` + (`apply_project_profiles`); `active_config_profiles` + + `profile_config_paths` on `ProjectConfig`; + `discover_with_profile` entry point (`discover` delegates with + `None` and reads `QUARTO_PROFILE` via `runtime.env_get`) +- [x] `_quarto.yml.local` early parse (profile.default) + final layer + (`.yml.local` preferred over `.yaml.local`) +- [x] Register overlay files everywhere merged-value FileIds can + surface: `MetadataMergeStage` register closure, render.rs + config-sources (×2), `RenderScriptsContext` (+5 construction + sites in render/publish/preview), `project_resources` (×2), + `compile_theme_css::theme_error_candidates` +- [x] Verify: profile-aware `output-dir` / `render:` lists / + pre-render scripts / resolved `ProjectContext.output_dir` via + tests +- Note: the `QUARTO_PROFILE`-env-var glue through `runtime.env_get` + is exercised end-to-end in Phase 2 (real process env through the + binary); unit-testing it would need a mock-env `SystemRuntime` + wrapper, which no other test needed yet. + +### Phase 2 — CLI + cache + subprocess env var + +- [x] Failing tests first: 14 binary-driven tests in + `crates/quarto/tests/integration/project_profile_cli.rs` + (comma + repeated flags, `--profile` replaces + `QUARTO_PROFILE`, get-config overlay values, Q-5-19/21 through + the binary, single-file strictness, `-v` echo, help presence) +- [x] Wire `--profile` into `RenderArgs` + all render/classify + discover sites (incl. the post-pre-render-script re-discovery) + + get-config + publish (threaded through + `ProjectPublishRenderer` so publish's render-time re-discovery + matches). **Preview: deferred to bd-pfgc273f** — + `QUARTO_PROFILE=x q2 preview` already works end-to-end (all + its discovers read the env via runtime); the flag form needs + HubContext threading, not rushed here. +- [x] Project-less discovery also resolves + validates the selection + (bad names abort even without `_quarto.yml`; no Q-5-19 there) +- [x] `-v` echo with per-profile provenance (`echo_active_profiles` + in commands/render.rs). Found + fixed two latent gaps: + `verbose_to_filter` never matched the `q2` bin crate's targets + (added `q2=` directives), and the tracing fmt layer wrote to + stdout (now stderr, where all q2 diagnostics live). +- [x] `Pass1KeyInputs` + `pass1_key`: active names (count-prefixed, + order-sensitive — first-listed-wins makes order semantic) + + overlay bytes; `PROFILE_KEY_VERSION` bumped to 2; orchestrator + fills from `profile_config_paths`; 4 new key tests incl. a + domain-separation test +- [x] `QUARTO_PROFILE` on child processes **unconditionally** + (override-inherited is the one exception to #486's + real-env-wins rule): engines via `EngineContext.project_env` + pair injection in `EngineExecutionStage`, render scripts via + `RenderScriptsContext.quarto_profile` (+real-spawn unix test); + `{{< env QUARTO_PROFILE >}}` special-cased in + `EnvShortcodeHandler` to beat the real env (unit-tested) +- [x] E2E (2026-08-10, recorded): fixture with + `profile: group: [draft, final]` + `_quarto-prod.yml` setting + `title`; `q2 render index.qmd --profile prod` → + `Production Title`; body + `{{< env QUARTO_PROFILE none >}}` → `Active profiles: + prod,draft` (normalized + group-expanded); `q2 get-config + index.qmd title --profile prod` → `"Production Title"` (and + `"Base Title"` without); `-v` echoes `active project profiles: + prod (from --profile), draft (from profile.group default)`. + Output inspected by grep on the rendered HTML. +- Note: real-engine (jupyter/knitr) visibility of `QUARTO_PROFILE` + is exercised manually in Phase 5 alongside the docs fixtures, same + policy as PR #486's engine-env verification. + +### Phase 3 — environment integration (after PR #486 merges) + +- [x] Rebased over #486 (merged 2026-08-10 15:38). Failing tests + first: 6 binary-driven tests (bootstrap activation, `.local` + bootstrap wins, real-env/CLI beat bootstrap, `_environment-

` + layering first-listed-wins via `{{< env >}}` in rendered HTML, + `.local` beats profile env files, no bootstrap recursion from + `_environment-

`) — 3 observed failing pre-implementation +- [x] `dotenv_quarto_profile` in `project/environment.rs` + (`.local` then `_environment`, never profile variants) feeds + `ProfileResolutionInputs.env_file`; the `&[]` seam in + `StageContext::new` and `subprocess_env_for_project` now pass + the active names (Q-5-19's env-file clause was already live + since Phase 1) +- [x] Closed bd-ev8mk1rp (implemented; preview flag split off as + bd-pfgc273f) + +### Phase 4 — conditional content (full trio) + +- [x] Failing tests first: 15 binary-driven tests in + `conditional_content_cli.rs` (14 observed failing) — div/span + visibility for when/unless × format/profile/meta, + AND-across-kinds, comma-OR-within-values, bare + `.content-hidden`, attribute stripping, nested conditionals, + when-meta reading profile-overlay metadata, crossref + renumbering (hidden `#fig-` float does not consume a number), + Q-2-42 typo warning — plus 11 unit tests on the pure walker +- [x] Implemented `transforms/conditional_content.rs` + (`ConditionalContentTransform`), registered **first in the + Normalization phase** of `build_transform_pipeline` + (format-agnostic; preview pipeline inherits it): hidden + content disappears before callouts/shortcodes/crossrefs. + Engine cells inside hidden blocks still execute (Q1 parity — + engines run in an earlier stage). +- [x] Format-alias matching: reuses pampa's + `lua::quarto_doc::is_format_match` (made pub), matched against + `lua_format_for(target_format)` — the attribute syntax and + Lua's `quarto.doc.is_format` can never disagree, and preview + pseudo-formats behave like render +- [x] Divergences settled in-code (module docs): comma/space OR + within one condition value (q2 extension; Q1 matches + literally — its repeated-attribute OR is unrepresentable in + q2's map-shaped Attr); Q-2-42 warnings for unknown `when-*`/ + `unless-*` spellings and for both-marker elements (hidden + wins); new catalog entry Q-2-42 +- [x] E2E (recorded): doc with visible/hidden/inline conditions; + `q2 render doc.qmd` → basic text only; `--profile advanced` → + advanced text + inline span, basic text gone (grep-verified + both ways). Bonus: the run exercised Q-5-19's hint for + conditional-content-only profiles exactly as designed. + +### Phase 5 — docs, fixtures, wrap-up + +- [x] `docs/guides/projects/profiles.qmd` written (activation, + merging, defaults/groups, conditional content, code + visibility, "Differences from Quarto 1" section mirroring the + divergence table); `environment.qmd` gained the + `_environment-` file row, precedence entry, and a + "Profile environments" section (incl. secrets callout and the + no-recursion rule). Rendered with + `cargo run --bin q2 -- render docs/` and inspected (titles, + literal shortcode escape, cross-links all correct). +- [x] smoke-all fixture `metadata/project-profiles/`: activation via + `profile.default` (no CLI flags needed, so all three runners + exercise it — including the WASM runner, our only WASM-path + coverage), overlay title + when-profile visible/hidden + + when-meta-from-overlay assertions; native runner green, WASM + runner exercised by `cargo xtask verify`'s hub leg. +- [x] Engine E2E (recorded): jupyter cell + `os.environ.get("QUARTO_PROFILE")` printed + `advanced,production` under + `q2 render doc.qmd --profile advanced,production` with a real + python kernel. +- [x] Deferred-work strands filed: bd-pfgc273f (preview --profile + threading), bd-spb7mobo (metadata-files decision), + bd-ip1lrgra (Lua quarto.project.profile), bd-kzwt3xcu + (preview watch/restart on profile-config change), + bd-47hhbmaj (auto-gitignore /_*.local). bd-mlj6 and + bd-ev8mk1rp closed as implemented. +- [ ] Full gates: `cargo xtask verify` (full, WASM leg) — running; + commit after green +- [ ] Write the "feature porting" process doc (session + retrospective) — on request, per the session brief + +## Open questions for plan iteration + +*(none — all resolved 2026-08-10; see "Decisions locked" above)* diff --git a/crates/pampa/src/lua/mod.rs b/crates/pampa/src/lua/mod.rs index 343a92a93..e9b2d7d4f 100644 --- a/crates/pampa/src/lua/mod.rs +++ b/crates/pampa/src/lua/mod.rs @@ -21,7 +21,7 @@ mod os_wasm; mod pandoc_doc; mod path; mod quarto_api; -mod quarto_doc; +pub mod quarto_doc; mod readwrite; pub mod runtime; pub mod shortcode; diff --git a/crates/pampa/src/lua/quarto_doc.rs b/crates/pampa/src/lua/quarto_doc.rs index 4bccb3b36..2319e0cf8 100644 --- a/crates/pampa/src/lua/quarto_doc.rs +++ b/crates/pampa/src/lua/quarto_doc.rs @@ -68,7 +68,10 @@ const UNSUPPORTED_FIELDS: &[&str] = &[ /// Check if FORMAT matches a query using TS Quarto's alias-based matching. /// `format` is the current FORMAT global value, `query` is what the extension asked for. -fn is_format_match(format: &str, query: &str) -> bool { +/// Public: quarto-core's conditional-content transform reuses this for +/// `when-format` / `unless-format` (bd-fu16z22k) so Lua's +/// `quarto.doc.is_format` and the attribute syntax can never disagree. +pub fn is_format_match(format: &str, query: &str) -> bool { // Exact match if format == query { return true; diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index 150902c7e..2e2bfb590 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -71,17 +71,18 @@ use crate::transforms::{ AppendixStructureTransform, AttributionRenderTransform, AttributionViewerTransform, AuthorsNormalizeTransform, CalloutResolveTransform, CalloutTransform, CategoriesSidebarTransform, CodeBlockGenerateTransform, CodeBlockRenderTransform, - CrossrefIndexTransform, CrossrefRenderTransform, CrossrefResolveTransform, - DateNormalizeTransform, EquationLabelTransform, ExampleEmbedRenderTransform, - ExampleEmbedTransform, FloatRefTargetSugarTransform, FooterGenerateTransform, - FooterRenderTransform, FootnotesTransform, LinkRewriteTransform, ListingGenerateTransform, - ListingRenderTransform, MermaidRenderTransform, MetadataNormalizeTransform, - NavbarGenerateTransform, NavbarRenderTransform, PageNavGenerateTransform, - PageNavRenderTransform, ProofSugarTransform, ResourceCollectorTransform, SectionizeTransform, - ShortcodeResolveTransform, SidebarGenerateTransform, SidebarRenderTransform, - TableBootstrapClassTransform, TheoremSugarTransform, TitleBannerTransform, TitleBlockTransform, - TocGenerateTransform, TocRenderTransform, WebsiteBootstrapIconsTransform, - WebsiteCanonicalUrlTransform, WebsiteFaviconTransform, WebsiteTitlePrefixTransform, + ConditionalContentTransform, CrossrefIndexTransform, CrossrefRenderTransform, + CrossrefResolveTransform, DateNormalizeTransform, EquationLabelTransform, + ExampleEmbedRenderTransform, ExampleEmbedTransform, FloatRefTargetSugarTransform, + FooterGenerateTransform, FooterRenderTransform, FootnotesTransform, LinkRewriteTransform, + ListingGenerateTransform, ListingRenderTransform, MermaidRenderTransform, + MetadataNormalizeTransform, NavbarGenerateTransform, NavbarRenderTransform, + PageNavGenerateTransform, PageNavRenderTransform, ProofSugarTransform, + ResourceCollectorTransform, SectionizeTransform, ShortcodeResolveTransform, + SidebarGenerateTransform, SidebarRenderTransform, TableBootstrapClassTransform, + TheoremSugarTransform, TitleBannerTransform, TitleBlockTransform, TocGenerateTransform, + TocRenderTransform, WebsiteBootstrapIconsTransform, WebsiteCanonicalUrlTransform, + WebsiteFaviconTransform, WebsiteTitlePrefixTransform, }; /// Well-known path for the default CSS artifact in WASM context. @@ -1178,6 +1179,7 @@ pub fn build_transform_pipeline( target_format: String, variables: Option, project_env: hashlink::LinkedHashMap, + quarto_profile: Option, ) -> TransformPipeline { let mut pipeline: TransformPipeline = TransformPipeline::new(); @@ -1194,6 +1196,11 @@ pub fn build_transform_pipeline( let lua_format = crate::format::lua_format_for(&target_format).to_string(); // === NORMALIZATION PHASE === + // Conditional content runs FIRST: hidden content must disappear + // before callouts assemble, shortcodes resolve (no spurious + // warnings from deliberately-excluded content), and long before + // crossref numbering (bd-fu16z22k Phase 4). + pipeline.push(Box::new(ConditionalContentTransform::new())); pipeline.push(Box::new(CalloutTransform::new())); pipeline.push(Box::new(CalloutResolveTransform::new())); // Markdown-parse blessed website presentation config strings @@ -1209,6 +1216,7 @@ pub fn build_transform_pipeline( lua_format, variables, project_env, + quarto_profile, ))); pipeline.push(Box::new(MetadataNormalizeTransform::new())); // Date normalization (bd-gx9cic8z P4): resolves today/now/ @@ -1548,6 +1556,7 @@ pub fn build_q2_preview_transform_pipeline( target_format: String, variables: Option, project_env: hashlink::LinkedHashMap, + quarto_profile: Option, ) -> TransformPipeline { let mut pipeline = build_transform_pipeline( shortcode_paths, @@ -1556,6 +1565,7 @@ pub fn build_q2_preview_transform_pipeline( target_format, variables, project_env, + quarto_profile, ); pipeline.retain_excluding(Q2_PREVIEW_TRANSFORM_EXCLUDED); pipeline @@ -2720,6 +2730,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let html_names: Vec<&str> = html.iter().map(|t| t.name()).collect(); @@ -3153,6 +3164,7 @@ mod tests { "q2-preview".to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); assert!( @@ -3177,6 +3189,7 @@ mod tests { "q2-preview".to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); for required in [ @@ -3230,6 +3243,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -3299,6 +3313,7 @@ mod tests { format.to_string(), None, Default::default(), + None, ); let steps: Vec<(&str, TransformPhase)> = pipeline.iter().map(|t| (t.name(), t.phase())).collect(); @@ -3347,6 +3362,7 @@ mod tests { "q2-preview".to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); for required in ["code-block-generate", "code-block-render"] { @@ -3373,6 +3389,7 @@ mod tests { format.to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); @@ -3406,6 +3423,7 @@ mod tests { format.to_string(), None, Default::default(), + None, ); let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect(); assert!( diff --git a/crates/quarto-core/src/project/cache_key.rs b/crates/quarto-core/src/project/cache_key.rs index 8447246fd..6d41334fc 100644 --- a/crates/quarto-core/src/project/cache_key.rs +++ b/crates/quarto-core/src/project/cache_key.rs @@ -87,7 +87,10 @@ use crate::document_profile::DOCUMENT_PROFILE_VERSION; /// Manual key-version constant. Bump when a head-pipeline behavior /// change alters what a profile records without changing /// `DOCUMENT_PROFILE_VERSION`. -pub const PROFILE_KEY_VERSION: u32 = 1; +/// +/// v2: the key domain gained project-profile inputs (active names + +/// overlay bytes, bd-fu16z22k). +pub const PROFILE_KEY_VERSION: u32 = 2; /// Returns the Quarto build identifier baked into every cache key. /// @@ -132,6 +135,22 @@ pub struct Pass1KeyInputs<'a> { /// is `(name, raw-metadata-bytes)`. Empty when no extensions /// apply. pub extension_contributions: &'a [(String, Vec)], + + /// Active **project-profile** names in activation order + /// (bd-fu16z22k). ⚠️ Both meanings of "profile" collide right + /// here: this field holds *project profiles* (`--profile` / + /// `QUARTO_PROFILE`), which are an input to the *DocumentProfile* + /// cache key this struct feeds — switching project profiles must + /// not serve stale pass-1 DocumentProfiles. Empty when none are + /// active. + pub active_config_profiles: &'a [String], + + /// `(project-relative-path, raw-bytes)` of every profile overlay + /// (`_quarto-.yml`) and `_quarto.yml.local` actually merged + /// into the project config, in merge order. Byte-level like + /// [`metadata_files`](Self::metadata_files): a comment-only edit + /// to an overlay correctly invalidates the key. + pub profile_config_files: &'a [(PathBuf, Vec)], } /// Compute the SHA-256 cache key for a `DocumentProfile`. @@ -163,6 +182,23 @@ pub fn pass1_key(inputs: &Pass1KeyInputs<'_>) -> [u8; 32] { // _quarto.yml bytes (empty slice when absent). write_lp_bytes(&mut hasher, inputs.quarto_yml_bytes); + // Project-profile activation + overlay bytes (bd-fu16z22k). The + // name list is hashed even when no overlay files exist: two runs + // differing only in `--profile` must not share keys (conditional + // content will depend on the active set). Each list is + // count-prefixed so a name list can never alias a path/bytes + // pair from the file list. With both lists empty the stream + // gains only two zero counts, keeping profile-less keys cheap. + hasher.update((inputs.active_config_profiles.len() as u32).to_be_bytes()); + for name in inputs.active_config_profiles { + write_lp_str(&mut hasher, name); + } + hasher.update((inputs.profile_config_files.len() as u32).to_be_bytes()); + for (path, bytes) in inputs.profile_config_files { + write_lp_str(&mut hasher, &path.to_string_lossy()); + write_lp_bytes(&mut hasher, bytes); + } + // Format-extension contributions, sorted by name (caller's // responsibility). Hashing in any other order would change the // key for the same set of contributions. @@ -222,9 +258,72 @@ mod tests { metadata_files: &[], quarto_yml_bytes: b"", extension_contributions: &[], + active_config_profiles: &[], + profile_config_files: &[], } } + #[test] + fn key_changes_on_active_profile_set() { + // Even with no overlay files on disk, a different --profile + // selection must change the key (bd-fu16z22k): conditional + // content depends on the active set. + let a = pass1_key(&minimal_inputs()); + let names = vec!["prod".to_string()]; + let mut tweaked = minimal_inputs(); + tweaked.active_config_profiles = &names; + assert_ne!(a, pass1_key(&tweaked)); + } + + #[test] + fn key_changes_on_profile_order() { + // First-listed-wins makes activation ORDER semantic. + let ab = vec!["a".to_string(), "b".to_string()]; + let ba = vec!["b".to_string(), "a".to_string()]; + let mut a = minimal_inputs(); + a.active_config_profiles = &ab; + let mut b = minimal_inputs(); + b.active_config_profiles = &ba; + assert_ne!(pass1_key(&a), pass1_key(&b)); + } + + #[test] + fn key_changes_on_overlay_byte_change() { + let f_a = vec![( + PathBuf::from("_quarto-prod.yml"), + b"toc: true +" + .to_vec(), + )]; + let f_b = vec![( + PathBuf::from("_quarto-prod.yml"), + b"toc: false +" + .to_vec(), + )]; + let names = vec!["prod".to_string()]; + let mut a = minimal_inputs(); + a.active_config_profiles = &names; + a.profile_config_files = &f_a; + let mut b = minimal_inputs(); + b.active_config_profiles = &names; + b.profile_config_files = &f_b; + assert_ne!(pass1_key(&a), pass1_key(&b)); + } + + #[test] + fn profile_name_list_cannot_alias_overlay_file_entry() { + // Count prefixes keep the two lists domain-separated: names + // ["p", "x"] must not hash like files [("p", b"x")]. + let names = vec!["p".to_string(), "x".to_string()]; + let files = vec![(PathBuf::from("p"), b"x".to_vec())]; + let mut a = minimal_inputs(); + a.active_config_profiles = &names; + let mut b = minimal_inputs(); + b.profile_config_files = &files; + assert_ne!(pass1_key(&a), pass1_key(&b)); + } + #[test] fn key_is_deterministic_for_identical_inputs() { let a = pass1_key(&minimal_inputs()); diff --git a/crates/quarto-core/src/project/environment.rs b/crates/quarto-core/src/project/environment.rs index 382f71a66..05587c918 100644 --- a/crates/quarto-core/src/project/environment.rs +++ b/crates/quarto-core/src/project/environment.rs @@ -7,8 +7,9 @@ */ //! Parser for project environment files (`_environment`, -//! `_environment.local`, `_environment.required`, and — once profiles -//! exist, bd-ev8mk1rp — `_environment-`). +//! `_environment.local`, `_environment.required`, and +//! `_environment-` for each active project profile +//! (bd-fu16z22k). //! //! Quarto 2 **never mutates the process environment**. Where Quarto 1 //! loads these files into the ambient env (`Deno.env.set`), q2 parses @@ -220,11 +221,40 @@ pub fn check_required( /// Load a project's environment files into a map, Q1-style. /// /// Files considered, priority highest first: `_environment.local`, -/// `_environment-` per active profile (activation order — -/// always empty until bd-ev8mk1rp lands render profiles), and +/// `_environment-` per active profile (activation order, +/// from `ProjectConfig::active_config_profiles` — bd-fu16z22k), and /// `_environment`. Missing files are normal. `_environment.required` /// contributes validation diagnostics only, never values. /// +/// Read `QUARTO_PROFILE` out of `_environment.local` / +/// `_environment` — Q1's `dotenvQuartoProfile` bootstrap +/// (bd-fu16z22k, Phase 3). `.local` wins; **profile variants are +/// deliberately not consulted** (no activation recursion — Q1 +/// parity). Runs *before* profile resolution, so it cannot use the +/// project env map; parse diagnostics are dropped here and resurface +/// from the full loader on every document render. +pub fn dotenv_quarto_profile( + runtime: &dyn SystemRuntime, + project_dir: &std::path::Path, +) -> Option { + let lookup = |name: &str| std::env::var(name).ok(); + for name in ["_environment.local", "_environment"] { + let path = project_dir.join(name); + let Ok(content) = runtime.file_read_string(&path) else { + continue; + }; + let parsed = parse_env_file(&content, &path.display().to_string(), &lookup); + if let Some(entry) = parsed + .entries + .into_iter() + .find(|e| e.key == crate::project::project_profile::QUARTO_PROFILE_VAR) + { + return Some(entry.value); + } + } + None +} + /// Project-scoped like `_variables.yml`: single-file renders get an /// empty map (Q1 parity — env files load during project-context /// creation there too). @@ -310,7 +340,13 @@ pub fn subprocess_env_for_project( project: &crate::project::ProjectContext, ) -> Vec<(String, String)> { let mut diagnostics = Vec::new(); - let map = load_project_environment(runtime, project, &[], &mut diagnostics); + let active_profile_names: Vec = project + .config + .active_config_profiles + .iter() + .map(|p| p.name.clone()) + .collect(); + let map = load_project_environment(runtime, project, &active_profile_names, &mut diagnostics); env_for_subprocess(&map) } diff --git a/crates/quarto-core/src/project/mod.rs b/crates/quarto-core/src/project/mod.rs index e8a30c6f1..ab325d342 100644 --- a/crates/quarto-core/src/project/mod.rs +++ b/crates/quarto-core/src/project/mod.rs @@ -32,6 +32,7 @@ pub mod listing; pub mod orchestrator; pub mod pass2_renderer; pub mod profile_cache; +pub mod project_profile; pub mod render_scripts; pub mod sidebar_membership; pub mod website_config; @@ -1089,6 +1090,30 @@ pub struct ProjectConfig { /// ([`crate::project::website_config::website_favicon`]); the /// navbar brand image is expected to join it (bd-hp3tx). pub brand: Option, + + /// The resolved **project-profile** activation set + /// (bd-fu16z22k), in activation order, with per-profile + /// provenance for the `-v` echo. + /// + /// ⚠️ Not related to [`crate::document_profile::DocumentProfile`] + /// (the pass-1 summary) — this is the Quarto 1 "project profiles" + /// feature (`--profile` / `QUARTO_PROFILE` / `_quarto-.yml`). + /// Empty when no profiles are active (including single-file + /// pseudo-projects and default-constructed configs). + pub active_config_profiles: Vec, + + /// Paths of the profile overlay files (`_quarto-.yml`) and + /// the local override (`_quarto.yml.local`) that were actually + /// read and merged into [`metadata`](Self::metadata), in merge + /// order (lowest priority first). + /// + /// Candidate source files for + /// [`crate::config_sources::bind_config_source`], alongside + /// [`config_path`](Self::config_path) and + /// [`extension_manifest_paths`](Self::extension_manifest_paths): + /// merged values keep the filename-hash FileId of the overlay + /// they were written in. Also a cache-key input (Phase 2). + pub profile_config_paths: Vec, } impl ProjectConfig { @@ -1173,6 +1198,49 @@ pub struct ProjectContext { pub output_dir: PathBuf, } +/// What [`ProjectContext::apply_project_profiles`] resolved: the +/// activation set and the overlay/local file paths actually merged. +struct ProfileApplyOutcome { + active: Vec, + paths: Vec, +} + +/// The file name of a config path, for diagnostics (`_quarto.yml`, +/// `_quarto-prod.yml`, …). Falls back to the full display string for +/// pathological paths. +fn file_label(path: &Path) -> String { + path.file_name() + .and_then(|n| n.to_str()) + .map_or_else(|| path.display().to_string(), str::to_string) +} + +/// Read + parse one profile config layer (`_quarto-.yml` or +/// `_quarto.yml.local`) into a source-tracked [`ConfigValue`], with +/// the same interpretation context as the base `_quarto.yml`. +/// Parse failures abort loudly (Q1 parity: a malformed profile file +/// stops the render). +fn read_config_layer(path: &Path, runtime: &dyn SystemRuntime) -> Result { + use pampa::pandoc::yaml_to_config_value; + use pampa::utils::diagnostic_collector::DiagnosticCollector; + use quarto_config::InterpretationContext; + + let content = runtime + .file_read_string(path) + .map_err(|e| QuartoError::Other(format!("Failed to read {}: {}", path.display(), e)))?; + let filename = path.to_string_lossy().to_string(); + let yaml = quarto_yaml::parse_file(&content, &filename) + .map_err(|e| QuartoError::Other(format!("Failed to parse {}: {}", path.display(), e)))?; + // Like the base-config parse above, tag diagnostics from the + // collector are not surfaced here; MetadataMergeStage re-collects + // them per document. + let mut collector = DiagnosticCollector::new(); + Ok(yaml_to_config_value( + yaml, + InterpretationContext::ProjectConfig, + &mut collector, + )) +} + impl ProjectContext { /// Discover project context from a path. /// @@ -1180,7 +1248,27 @@ impl ProjectContext { /// If the path is a directory, looks for `_quarto.yml` in that directory and parents. /// /// If no `_quarto.yml` is found, creates a single-file pseudo-project. + /// + /// Project-profile activation (bd-fu16z22k) reads the + /// `QUARTO_PROFILE` environment variable through the runtime; to + /// pass an explicit `--profile` selection instead, use + /// [`Self::discover_with_profile`]. pub fn discover(path: impl AsRef, runtime: &dyn SystemRuntime) -> Result { + Self::discover_with_profile(path, runtime, None) + } + + /// [`Self::discover`] with an explicit project-profile selection. + /// + /// `cli_selection` carries `--profile` values (each may itself be + /// a comma/space-separated list). `Some` **replaces** the + /// `QUARTO_PROFILE` environment variable entirely (Q1 parity); + /// `None` falls back to the environment variable and the config + /// defaults. See [`project_profile`] for the precedence chain. + pub fn discover_with_profile( + path: impl AsRef, + runtime: &dyn SystemRuntime, + cli_selection: Option<&[String]>, + ) -> Result { let path = path.as_ref(); // Canonicalize the path @@ -1213,7 +1301,7 @@ impl ProjectContext { }; // Search for _quarto.yml - let (project_dir, config) = Self::find_project_config(&search_dir, runtime)?; + let (project_dir, config) = Self::find_project_config(&search_dir, runtime, cli_selection)?; // Determine if this is a single-file project let is_single_file = config.is_none() && input_file.is_some(); @@ -1249,9 +1337,48 @@ impl ProjectContext { paths.into_iter().map(DocumentInfo::from_path).collect() }; + // Project-less discovery (no `_quarto.yml` found): still + // resolve profile activation from the explicit selection / + // `QUARTO_PROFILE`, so `--profile bad/name` errors here too + // and conditional content (`when-profile`) sees the active + // set. There are no overlays, env files, or declarations to + // match, so the Q-5-19 unknown-profile warning never fires. + let config = match config { + Some(config) => config, + None => { + let mut diagnostics = Vec::new(); + let env_profile = runtime + .env_get(project_profile::QUARTO_PROFILE_VAR) + .ok() + .flatten(); + let active = project_profile::resolve_active_profiles( + &project_profile::ProfileResolutionInputs { + cli: cli_selection, + env_var: env_profile.as_deref(), + ..Default::default() + }, + &mut diagnostics, + ); + if diagnostics + .iter() + .any(|d| d.kind == quarto_error_reporting::DiagnosticKind::Error) + { + return Err(QuartoError::Parse(crate::error::ParseError::new( + diagnostics, + quarto_source_map::SourceContext::new(), + ))); + } + ProjectConfig { + active_config_profiles: active, + config_diagnostics: diagnostics, + ..Default::default() + } + } + }; + Ok(Self { dir, - config: config.unwrap_or_default(), + config, is_single_file, files, output_dir, @@ -1284,6 +1411,7 @@ impl ProjectContext { fn find_project_config( start_dir: &Path, runtime: &dyn SystemRuntime, + cli_selection: Option<&[String]>, ) -> Result<(Option, Option)> { let mut current = start_dir.to_path_buf(); @@ -1294,7 +1422,7 @@ impl ProjectContext { .map_err(|e| QuartoError::Other(format!("Failed to check config path: {}", e)))?; if exists { // Found config file - parse it - let config = Self::parse_config(&config_path, runtime)?; + let config = Self::parse_config(&config_path, runtime, cli_selection)?; return Ok((Some(current), Some(config))); } @@ -1304,7 +1432,7 @@ impl ProjectContext { .path_exists(&config_path_yaml, None) .map_err(|e| QuartoError::Other(format!("Failed to check config path: {}", e)))?; if exists_yaml { - let config = Self::parse_config(&config_path_yaml, runtime)?; + let config = Self::parse_config(&config_path_yaml, runtime, cli_selection)?; return Ok((Some(current), Some(config))); } @@ -1319,7 +1447,11 @@ impl ProjectContext { } /// Parse a `_quarto.yml` file - fn parse_config(path: &Path, runtime: &dyn SystemRuntime) -> Result { + fn parse_config( + path: &Path, + runtime: &dyn SystemRuntime, + cli_selection: Option<&[String]>, + ) -> Result { use pampa::pandoc::yaml_to_config_value; use pampa::utils::diagnostic_collector::DiagnosticCollector; use quarto_config::InterpretationContext; @@ -1341,6 +1473,24 @@ impl ProjectContext { let mut metadata = yaml_to_config_value(yaml, InterpretationContext::ProjectConfig, &mut diagnostics); + // ── Project profiles (bd-fu16z22k) ────────────────────────── + // Resolve the activation set and merge `_quarto-.yml` + // overlays plus `_quarto.yml.local` into `metadata` *before* + // any field below is extracted, so `project.type`, + // `output-dir`, `render`, `resources`, render scripts, and + // `brand` are all profile-aware. ("Profiles" here = project + // profiles, not DocumentProfile.) + let mut profile_diagnostics: Vec = Vec::new(); + let profile_outcome = Self::apply_project_profiles( + &mut metadata, + path, + runtime, + cli_selection, + &mut profile_diagnostics, + )?; + let active_config_profiles = profile_outcome.active; + let profile_config_paths = profile_outcome.paths; + // Discover project-scoped extensions once (bd-ad7i1pc6): both // custom-type resolution and `contributes.metadata.project` // merging consume the same list. Manifest-load failures @@ -1421,10 +1571,15 @@ impl ProjectContext { .map(|ext| ext.path.join("_extension.yml")) .collect(); + // Profile warnings first (they arose first), then + // type-resolution warnings. + let mut config_diagnostics = profile_diagnostics; + config_diagnostics.extend(resolved.diagnostics); + Ok(ProjectConfig { project_kind, custom_project_type: resolved.custom, - config_diagnostics: resolved.diagnostics, + config_diagnostics, output_dir, render_patterns, resources, @@ -1434,9 +1589,237 @@ impl ProjectContext { config_path: Some(path.to_path_buf()), extension_manifest_paths, brand, + active_config_profiles, + profile_config_paths, }) } + /// Resolve project-profile activation and merge the overlay + /// layers into `metadata` (bd-fu16z22k, Phase 1). + /// + /// Steps, in order: + /// 1. extract + strip `profile:` from the base config; + /// 2. parse `_quarto.yml.local` (if present) — its + /// `profile.default` feeds activation, its remaining content + /// becomes the highest-priority merge layer; + /// 3. resolve the activation set (explicit `cli_selection` + /// replaces `QUARTO_PROFILE` from the runtime environment, + /// which beats the config defaults; then group expansion); + /// 4. read `_quarto-.yml` overlays in reverse activation + /// order, so the **first-listed** profile merges last and wins + /// conflicts (Q1 parity); + /// 5. warn (Q-5-19) about active profiles that match nothing; + /// 6. abort on any error-severity profile diagnostic, with the + /// config files registered so spans render; + /// 7. merge `[base, overlays…, local]` and replace `metadata`. + /// + /// Warnings are left in `diagnostics` for the caller to surface + /// via `config_diagnostics`. + fn apply_project_profiles( + metadata: &mut ConfigValue, + path: &Path, + runtime: &dyn SystemRuntime, + cli_selection: Option<&[String]>, + diagnostics: &mut Vec, + ) -> Result { + use project_profile::{ + ProfileKeySite, ProfileResolutionInputs, ProfileSource, extract_profile_config, + resolve_active_profiles, + }; + + let config_dir = path.parent().unwrap_or(Path::new(".")); + let base_label = file_label(path); + + let profile_config = extract_profile_config( + metadata, + ProfileKeySite::BaseConfig, + &base_label, + diagnostics, + ); + + // `_quarto.yml.local` / `_quarto.yaml.local` (Q1's extension + // order: `_quarto` + `.yml` + `.local`; `.yml` preferred). + let local_path = ["_quarto.yml.local", "_quarto.yaml.local"] + .iter() + .map(|name| config_dir.join(name)) + .find(|p| matches!(runtime.path_exists(p, None), Ok(true))); + let mut local_default: Vec = Vec::new(); + let local_layer: Option<(PathBuf, ConfigValue)> = match &local_path { + Some(local) => { + let mut value = read_config_layer(local, runtime)?; + let local_profile = extract_profile_config( + &mut value, + ProfileKeySite::LocalConfig, + &file_label(local), + diagnostics, + ); + local_default = local_profile.default; + Some((local.clone(), value)) + } + None => None, + }; + + // Explicit CLI selection replaces the environment variable + // entirely (Q1 parity); the env var is read through the + // runtime so WASM/hub runtimes simply report it unset. + let env_profile = runtime + .env_get(project_profile::QUARTO_PROFILE_VAR) + .ok() + .flatten(); + let env_file_profile = environment::dotenv_quarto_profile(runtime, config_dir); + let active = resolve_active_profiles( + &ProfileResolutionInputs { + cli: cli_selection, + env_var: env_profile.as_deref(), + env_file: env_file_profile.as_deref(), + local_default: &local_default, + config: &profile_config, + }, + diagnostics, + ); + + // Overlays in reverse activation order: the first-listed + // profile is merged last, so it wins conflicts. + let mut overlay_layers: Vec<(PathBuf, ConfigValue)> = Vec::new(); + let mut matched_names: Vec<&str> = Vec::new(); + for profile in active.iter().rev() { + let overlay = ["yml", "yaml"] + .iter() + .map(|ext| config_dir.join(format!("_quarto-{}.{ext}", profile.name))) + .find(|p| matches!(runtime.path_exists(p, None), Ok(true))); + let Some(overlay) = overlay else { + continue; + }; + let mut value = read_config_layer(&overlay, runtime)?; + // A `profile:` key inside an overlay is inert: Q-5-22. + extract_profile_config( + &mut value, + ProfileKeySite::Overlay, + &file_label(&overlay), + diagnostics, + ); + overlay_layers.push((overlay, value)); + matched_names.push(profile.name.as_str()); + } + + // Q-5-19: an explicitly-selected profile that matches nothing + // is probably a typo. Config-sourced profiles are declared by + // construction; an `_environment-` file or a + // declaration under `profile:` also makes a name known. + for profile in &active { + let explicit = matches!( + profile.source, + ProfileSource::CliArg | ProfileSource::EnvVar | ProfileSource::EnvironmentFile + ); + if !explicit || matched_names.contains(&profile.name.as_str()) { + continue; + } + let declared = profile_config.default.iter().any(|n| n == &profile.name) + || local_default.iter().any(|n| n == &profile.name) + || profile_config + .groups + .iter() + .any(|g| g.iter().any(|n| n == &profile.name)); + let has_env_file = matches!( + runtime.path_exists( + &config_dir.join(format!("_environment-{}", profile.name)), + None + ), + Ok(true) + ); + if declared || has_env_file { + continue; + } + diagnostics.push( + quarto_error_reporting::DiagnosticMessageBuilder::warning(format!( + "Project profile `{}` matches nothing in this project", + profile.name + )) + .with_code("Q-5-19") + .problem(format!( + "`{}` (from {}) selects no configuration: there is no \ + `_quarto-{}.yml`, no `_environment-{}`, and the name is not \ + declared under `profile:` in `{base_label}`. This is usually \ + a typo.", + profile.name, + profile.source.describe(), + profile.name, + profile.name, + )) + .add_hint(format!( + "If the profile exists only for conditional content, declare \ + it under `profile.group` or `profile.default` in \ + `{base_label}` to silence this warning.", + )) + .build(), + ); + } + + // Error-severity diagnostics abort discovery, with every + // config file registered so spans render against the right + // text (bd-m6wmztln discipline). + if diagnostics + .iter() + .any(|d| d.kind == quarto_error_reporting::DiagnosticKind::Error) + { + let mut source_context = quarto_source_map::SourceContext::new(); + crate::config_sources::register_config_source(&mut source_context, path); + if let Some(local) = &local_path { + crate::config_sources::register_config_source(&mut source_context, local); + } + for (overlay, _) in &overlay_layers { + crate::config_sources::register_config_source(&mut source_context, overlay); + } + return Err(QuartoError::Parse(crate::error::ParseError::new( + std::mem::take(diagnostics), + source_context, + ))); + } + + // Merge: base (lowest), overlays (reverse activation order), + // `_quarto.yml.local` (highest). + let mut paths: Vec = overlay_layers.iter().map(|(p, _)| p.clone()).collect(); + if let Some((local, _)) = &local_layer { + paths.push(local.clone()); + } + if !overlay_layers.is_empty() || local_layer.is_some() { + let mut layers: Vec<&ConfigValue> = vec![metadata]; + layers.extend(overlay_layers.iter().map(|(_, v)| v)); + if let Some((_, v)) = &local_layer { + layers.push(v); + } + let merged = quarto_config::merge_with_diagnostics(layers, diagnostics) + .map(|m| m.materialize()) + .and_then(|r| match r { + Ok(v) => Some(v), + Err(e) => { + diagnostics.push( + quarto_error_reporting::DiagnosticMessageBuilder::error( + "Failed to merge project profile configuration", + ) + .with_code("Q-1-23") + .problem(format!( + "Merging the profile configuration layers failed: {e}" + )) + .build(), + ); + None + } + }); + match merged { + Some(merged) => *metadata = merged, + None => { + return Err(QuartoError::Parse(crate::error::ParseError::new( + std::mem::take(diagnostics), + quarto_source_map::SourceContext::new(), + ))); + } + } + } + + Ok(ProfileApplyOutcome { active, paths }) + } + /// Get the project kind. pub fn project_kind(&self) -> ProjectKind { self.config.project_kind diff --git a/crates/quarto-core/src/project/orchestrator.rs b/crates/quarto-core/src/project/orchestrator.rs index 055e1c5e1..91fccc3be 100644 --- a/crates/quarto-core/src/project/orchestrator.rs +++ b/crates/quarto-core/src/project/orchestrator.rs @@ -1708,6 +1708,30 @@ async fn pass1_profile_with_cache( // §Decision 2 footnote for the rationale. let extension_contributions: Vec<(String, Vec)> = Vec::new(); + // Project-profile inputs (bd-fu16z22k): active names plus raw + // bytes of every merged overlay / `_quarto.yml.local`, so a + // profile switch or overlay edit invalidates cached pass-1 + // DocumentProfiles. Paths are hashed project-relative for + // machine-independence, same policy as `metadata_files`. + let active_profile_names: Vec = project + .config + .active_config_profiles + .iter() + .map(|p| p.name.clone()) + .collect(); + let profile_config_files: Vec<(std::path::PathBuf, Vec)> = project + .config + .profile_config_paths + .iter() + .map(|path| { + let rel = path + .strip_prefix(&project.dir) + .map_or_else(|_| path.clone(), std::path::Path::to_path_buf); + let bytes = runtime.file_read(path).unwrap_or_default(); + (rel, bytes) + }) + .collect(); + let key_inputs = crate::project::cache_key::Pass1KeyInputs { format_id: &format_id, source_path: &source_path, @@ -1715,6 +1739,8 @@ async fn pass1_profile_with_cache( metadata_files: &metadata_files, quarto_yml_bytes: &quarto_yml_bytes, extension_contributions: &extension_contributions, + active_config_profiles: &active_profile_names, + profile_config_files: &profile_config_files, }; let key_bytes = crate::project::cache_key::pass1_key(&key_inputs); let key_hex = crate::project::cache_key::hex_encode(&key_bytes); diff --git a/crates/quarto-core/src/project/project_profile.rs b/crates/quarto-core/src/project/project_profile.rs new file mode 100644 index 000000000..cfc1c7d31 --- /dev/null +++ b/crates/quarto-core/src/project/project_profile.rs @@ -0,0 +1,1334 @@ +/* + * project_profile.rs + * Copyright (c) 2026 Posit, PBC + * + * Project profiles: activation resolution (bd-fu16z22k). + */ + +//! Project profiles — activation resolution and `profile:` config +//! extraction (bd-fu16z22k). +//! +//! ⚠️ **Terminology**: this module implements *project profiles* (the +//! Quarto 1 feature: `--profile`, `QUARTO_PROFILE`, +//! `_quarto-.yml` overlays). It is unrelated to +//! [`crate::document_profile::DocumentProfile`], the pass-1 document +//! summary, or to its cache under the `"profiles"` namespace. Code in +//! this module never uses a bare `profiles` identifier for the active +//! set — it is always `active_config_profiles` or `ActiveProfile`. +//! +//! This module is the pure core: profile-string parsing, strict name +//! validation, `profile:` key extraction (with stripping), and the +//! activation-precedence algorithm. It performs no I/O; discovery of +//! `_quarto-.yml` overlay files and their merging live in +//! [`super::ProjectContext::parse_config`] (Phase 1 of the plan at +//! `claude-notes/plans/2026-08-10-project-profiles-port.md`). +//! +//! # Quarto 1 semantics ported here +//! +//! The activation-precedence chain (first non-empty source wins): +//! 1. `--profile` CLI values ([`ProfileSource::CliArg`]) — *replaces* +//! `QUARTO_PROFILE`, never merges with it (Q1 parity); +//! 2. the `QUARTO_PROFILE` environment variable; +//! 3. `QUARTO_PROFILE` defined in `_environment.local` / +//! `_environment` (the "dotenv bootstrap"; wired in Phase 3); +//! 4. `profile.default` in `_quarto.yml.local`; +//! 5. `profile.default` in `_quarto.yml`. +//! +//! Then group expansion runs regardless of which source won: for each +//! group in `profile.group` (honored from `_quarto.yml` only), if no +//! member is active, the group's **first** member is appended. Group +//! defaults come after explicit selections, giving them lower overlay +//! precedence ("first-listed wins" — see the plan's precedence +//! decision). +//! +//! # Deliberate divergences from Quarto 1 (strictness) +//! +//! Q1 silently tolerates malformed input in this area; Q2 diagnoses +//! it (see the plan's divergence table): +//! - profile names are validated against +//! [`is_valid_profile_name`] (Q-5-21 error); +//! - a fully-empty explicit selection (`--profile ""`, +//! `QUARTO_PROFILE=" , "`) is a Q-5-21 error instead of silently +//! meaning "no profiles" (an *unset or empty-string* env var is +//! still "no selection", so `QUARTO_PROFILE= q2 render` unsets); +//! - shape errors under `profile:` (mixed-shape `group`, non-string +//! entries, unknown keys, non-map value) are Q-5-20 errors instead +//! of being silently ignored; +//! - `profile.group` in `_quarto.yml.local` and any `profile:` key in +//! a `_quarto-.yml` overlay are inert in Q1; Q2 warns +//! (Q-5-22) and strips them; +//! - duplicate names are dropped (first occurrence wins) instead of +//! being processed twice. + +use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder}; +use quarto_pandoc_types::{ConfigValue, ConfigValueKind}; +use quarto_source_map::SourceInfo; + +/// The environment variable holding the active project-profile list +/// (comma/space-separated). Read at activation time; exported to +/// child processes (engines, render scripts) in Phase 2 — never +/// written into this process's environment. +pub const QUARTO_PROFILE_VAR: &str = "QUARTO_PROFILE"; + +/// Interpret a clap `Vec` `--profile` flag as a selection: +/// an empty vec means the flag was not given (`None`, fall through to +/// `QUARTO_PROFILE` and config defaults); a non-empty vec — even one +/// holding only an empty string — means it was given and **replaces** +/// the environment variable (Q1 parity). +pub fn cli_selection(values: &[String]) -> Option<&[String]> { + if values.is_empty() { + None + } else { + Some(values) + } +} + +/// The `QUARTO_PROFILE` value user code (engine cells, render +/// scripts) sees: the **normalized, group-expanded** active list, +/// comma-joined — Q1 wrote exactly this back into the environment. +/// `None` when no profiles are active (children then inherit +/// whatever the parent environment has, which given an empty active +/// set can only be unset-or-empty). +/// +/// Spawn sites must apply this pair **unconditionally** via +/// `Command::env`, overriding an inherited `QUARTO_PROFILE`: after +/// `--profile b` replaced `QUARTO_PROFILE=a`, children must see `b`. +/// This is the one deliberate exception to the "real environment +/// always wins" rule of +/// [`crate::project::environment::env_for_subprocess`] — the +/// variable is quarto's own, not the user's. +pub fn quarto_profile_env_value(active: &[ActiveProfile]) -> Option { + if active.is_empty() { + return None; + } + Some( + active + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(","), + ) +} + +/// Typed contents of a `profile:` config key after strict validation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProjectProfileConfig { + /// `profile.default`: profiles activated when no higher-priority + /// source selects any. A bare string normalizes to one element. + pub default: Vec, + /// `profile.group`: groups of mutually-exclusive profiles; at + /// least one member of each group is always active (the first + /// member is the group's default). A flat list of strings + /// normalizes to a single group. + pub groups: Vec>, +} + +/// Where a `profile:` key was found, which controls what is honored +/// and what is diagnosed by [`extract_profile_config`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileKeySite { + /// `_quarto.yml`: both `default` and `group` are honored. + BaseConfig, + /// `_quarto.yml.local`: only `default` is honored; a `group` key + /// draws a Q-5-22 warning (Q1 reads groups from the base config + /// only). + LocalConfig, + /// A `_quarto-.yml` overlay: the whole `profile:` key is + /// inert (no recursion in Q1) — Q-5-22 warning, nothing honored. + Overlay, +} + +/// Which activation source put a profile into the active set. +/// Recorded for the `-v` echo and for diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileSource { + /// `--profile` on the command line. + CliArg, + /// The `QUARTO_PROFILE` environment variable. + EnvVar, + /// `QUARTO_PROFILE` defined in `_environment` / + /// `_environment.local` (dotenv bootstrap; Phase 3). + EnvironmentFile, + /// `profile.default` in `_quarto.yml.local`. + LocalConfigDefault, + /// `profile.default` in `_quarto.yml`. + ConfigDefault, + /// Appended by group expansion (`profile.group` first member). + GroupDefault, +} + +impl ProfileSource { + /// Human-readable origin for the `-v` echo and diagnostics. + pub fn describe(self) -> &'static str { + match self { + ProfileSource::CliArg => "--profile", + ProfileSource::EnvVar => "QUARTO_PROFILE", + ProfileSource::EnvironmentFile => "QUARTO_PROFILE (from environment file)", + ProfileSource::LocalConfigDefault => "profile.default (_quarto.yml.local)", + ProfileSource::ConfigDefault => "profile.default (_quarto.yml)", + ProfileSource::GroupDefault => "profile.group default", + } + } +} + +/// One active project profile with its activation provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveProfile { + pub name: String, + pub source: ProfileSource, +} + +/// Inputs to [`resolve_active_profiles`], in precedence order. +#[derive(Debug)] +pub struct ProfileResolutionInputs<'a> { + /// `--profile` values (each may itself be a comma/space-separated + /// list). `Some` means the flag was given, even with no usable + /// names — an explicitly-empty selection is an error, not a + /// fall-through. + pub cli: Option<&'a [String]>, + /// The real `QUARTO_PROFILE` environment variable. An empty + /// string is treated as unset. + pub env_var: Option<&'a str>, + /// `QUARTO_PROFILE` from `_environment.local` / `_environment` + /// (Phase 3 wires this; until then callers pass `None`). + pub env_file: Option<&'a str>, + /// `profile.default` extracted from `_quarto.yml.local`. + pub local_default: &'a [String], + /// The `profile:` config from `_quarto.yml`. + pub config: &'a ProjectProfileConfig, +} + +impl Default for ProfileResolutionInputs<'_> { + fn default() -> Self { + static EMPTY: ProjectProfileConfig = ProjectProfileConfig { + default: Vec::new(), + groups: Vec::new(), + }; + Self { + cli: None, + env_var: None, + env_file: None, + local_default: &[], + config: &EMPTY, + } + } +} + +/// Split a `QUARTO_PROFILE`-style string on commas and/or spaces +/// (Q1's `/[ ,]+/`), dropping empty segments and duplicate names +/// (first occurrence wins). Colons are **not** separators — Q1 +/// parity; a `a:b` segment survives as one (invalid) name for +/// [`is_valid_profile_name`] to reject with a targeted hint. +pub fn parse_profile_string(s: &str) -> Vec { + let mut names: Vec = Vec::new(); + for token in s.split([' ', ',']) { + if token.is_empty() { + continue; + } + if !names.iter().any(|n| n == token) { + names.push(token.to_string()); + } + } + names +} + +/// Strict profile-name check: `[A-Za-z0-9][A-Za-z0-9._-]*` +/// (filename-safe, no leading `.`, no whitespace, ASCII-only). +/// Decided 2026-08-10; see the plan's divergence table. +pub fn is_valid_profile_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +/// Extract — and **strip** — the top-level `profile:` key from a +/// parsed project config, validating strictly. +/// +/// The key is removed from `metadata` even when malformed, so no +/// downstream consumer (metadata merge, writers) ever sees it — Q1 +/// deletes it from the base config too (`initializeProfileConfig`). +/// `file_label` names the file in diagnostics (e.g. `_quarto.yml`). +/// +/// Diagnostics: Q-5-20 (shape), Q-5-21 (names), Q-5-22 (key at a +/// site where it is inert). Error-severity diagnostics mean the +/// returned config omits the offending entries; the caller decides +/// whether to abort (parse_config does). +pub fn extract_profile_config( + metadata: &mut ConfigValue, + site: ProfileKeySite, + file_label: &str, + diagnostics: &mut Vec, +) -> ProjectProfileConfig { + let Some(entry) = take_top_level_entry(metadata, "profile") else { + return ProjectProfileConfig::default(); + }; + + if site == ProfileKeySite::Overlay { + diagnostics.push( + DiagnosticMessageBuilder::warning("`profile:` has no effect in a profile overlay") + .with_code("Q-5-22") + .problem(format!( + "The `profile:` key in `{file_label}` is ignored. Profile overlay \ + files never contribute profile configuration — `profile.default` \ + and `profile.group` are read from `_quarto.yml` (and \ + `profile.default` from `_quarto.yml.local`)." + )) + .with_location(entry.key_source) + .build(), + ); + return ProjectProfileConfig::default(); + } + + let ConfigValueKind::Map(entries) = &entry.value.value else { + diagnostics.push( + DiagnosticMessageBuilder::error("`profile:` must be a mapping") + .with_code("Q-5-20") + .problem(format!( + "The `profile:` key in `{file_label}` must be a mapping with the \ + keys `default` and/or `group`, not a bare value." + )) + .add_hint( + "To set the profiles used when none are requested, write \ + `profile:` / ` default: `.", + ) + .with_location(entry.value.source_info) + .build(), + ); + return ProjectProfileConfig::default(); + }; + + let mut config = ProjectProfileConfig::default(); + for e in entries { + match e.key.as_str() { + "default" => { + config.default = extract_default_names(&e.value, file_label, diagnostics); + } + "group" => { + if site == ProfileKeySite::LocalConfig { + diagnostics.push( + DiagnosticMessageBuilder::warning( + "`profile.group` has no effect in `_quarto.yml.local`", + ) + .with_code("Q-5-22") + .problem(format!( + "The `profile.group` key in `{file_label}` is ignored: \ + profile groups are read from `_quarto.yml` only; \ + `{file_label}` contributes only `profile.default`." + )) + .with_location(e.key_source.clone()) + .build(), + ); + } else { + config.groups = extract_groups(&e.value, file_label, diagnostics); + } + } + unknown => { + diagnostics.push( + DiagnosticMessageBuilder::error(format!( + "Unknown key `{unknown}` under `profile:`" + )) + .with_code("Q-5-20") + .problem(format!( + "`profile.{unknown}` in `{file_label}` is not a recognized \ + key. The `profile:` mapping accepts only `default` and \ + `group`." + )) + .with_location(e.key_source.clone()) + .build(), + ); + } + } + } + config +} + +/// Remove and return the top-level entry named `key` from a map-shaped +/// [`ConfigValue`]. Returns `None` when `metadata` is not a map or has +/// no such entry. +fn take_top_level_entry( + metadata: &mut ConfigValue, + key: &str, +) -> Option { + let ConfigValueKind::Map(entries) = &mut metadata.value else { + return None; + }; + let idx = entries.iter().position(|e| e.key == key)?; + Some(entries.remove(idx)) +} + +/// Parse `profile.default`: a string scalar or a list of strings, +/// each strictly validated. Malformed entries are dropped with a +/// diagnostic; valid entries are kept (the error severity aborts the +/// render upstream regardless). +fn extract_default_names( + value: &ConfigValue, + file_label: &str, + diagnostics: &mut Vec, +) -> Vec { + let scalars: Vec<&ConfigValue> = if let Some(arr) = value.as_array() { + arr.iter().collect() + } else { + vec![value] + }; + let mut names = Vec::new(); + for scalar in scalars { + let Some(name) = scalar.as_str() else { + diagnostics.push( + DiagnosticMessageBuilder::error("`profile.default` entries must be strings") + .with_code("Q-5-20") + .problem(format!( + "`profile.default` in `{file_label}` must be a profile name \ + or a list of profile names; this entry is not a string." + )) + .with_location(scalar.source_info.clone()) + .build(), + ); + continue; + }; + if validate_profile_name_diagnosed( + name, + Some(scalar.source_info.clone()), + &format!("`profile.default` in `{file_label}`"), + diagnostics, + ) && !names.iter().any(|n| n == name) + { + names.push(name.to_string()); + } + } + names +} + +/// Parse `profile.group`: a flat list of strings (one group) or a +/// list of lists of strings (many groups). A mixed-shape list is a +/// Q-5-20 error yielding no groups (Q1 silently yields none); an +/// empty group or a group with an invalid member is dropped with a +/// diagnostic while other groups survive. +fn extract_groups( + value: &ConfigValue, + file_label: &str, + diagnostics: &mut Vec, +) -> Vec> { + let Some(items) = value.as_array() else { + diagnostics.push( + DiagnosticMessageBuilder::error("`profile.group` must be a list") + .with_code("Q-5-20") + .problem(format!( + "`profile.group` in `{file_label}` must be a list of profile \ + names (one group) or a list of such lists (several groups)." + )) + .with_location(value.source_info.clone()) + .build(), + ); + return Vec::new(); + }; + + let all_scalar = items.iter().all(|i| !i.is_array()); + let all_lists = items.iter().all(|i| i.is_array()); + if !all_scalar && !all_lists { + diagnostics.push( + DiagnosticMessageBuilder::error("`profile.group` mixes names and lists") + .with_code("Q-5-20") + .problem(format!( + "`profile.group` in `{file_label}` mixes bare profile names with \ + lists. Write either one flat list of names (a single group) or \ + a list of lists (one per group)." + )) + .with_location(value.source_info.clone()) + .build(), + ); + return Vec::new(); + } + + let group_values: Vec<&ConfigValue> = if all_lists { + items.iter().collect() + } else { + vec![value] + }; + + let mut groups = Vec::new(); + for group_value in group_values { + let members = group_value.as_array().unwrap_or_default(); + if members.is_empty() { + diagnostics.push( + DiagnosticMessageBuilder::error("Empty profile group") + .with_code("Q-5-20") + .problem(format!( + "A group in `profile.group` in `{file_label}` is empty. Each \ + group needs at least one profile name — the first member is \ + the group's default." + )) + .with_location(group_value.source_info.clone()) + .build(), + ); + continue; + } + let mut names = Vec::new(); + let mut valid = true; + for member in members { + let Some(name) = member.as_str() else { + diagnostics.push( + DiagnosticMessageBuilder::error("`profile.group` members must be strings") + .with_code("Q-5-20") + .problem(format!( + "A group member in `profile.group` in `{file_label}` is \ + not a string." + )) + .with_location(member.source_info.clone()) + .build(), + ); + valid = false; + continue; + }; + if !validate_profile_name_diagnosed( + name, + Some(member.source_info.clone()), + &format!("`profile.group` in `{file_label}`"), + diagnostics, + ) { + valid = false; + continue; + } + names.push(name.to_string()); + } + // A group with any invalid member is dropped wholesale: its + // first-member-default semantics can't be trusted anymore. + if valid { + groups.push(names); + } + } + groups +} + +/// Validate one profile name, emitting a Q-5-21 error when invalid. +/// Returns whether the name is valid. +fn validate_profile_name_diagnosed( + name: &str, + span: Option, + origin: &str, + diagnostics: &mut Vec, +) -> bool { + if is_valid_profile_name(name) { + return true; + } + let mut builder = DiagnosticMessageBuilder::error("Invalid project profile name") + .with_code("Q-5-21") + .problem(format!( + "`{name}` (from {origin}) is not a valid profile name. Profile names \ + must start with an ASCII letter or digit and contain only ASCII \ + letters, digits, `.`, `_`, and `-` — they name files such as \ + `_quarto-.yml`." + )); + if name.contains(':') { + builder = builder.add_hint( + "Profiles are separated by commas (for example `a,b`), not colons — \ + was this meant as a list?", + ); + } + if let Some(span) = span { + builder = builder.with_location(span); + } + diagnostics.push(builder.build()); + false +} + +/// Resolve the active project-profile set from all activation +/// sources. Pure: no I/O, no process-environment reads — callers +/// supply every input ([`ProfileResolutionInputs`]). +/// +/// Returns profiles in **activation order** (explicit selections +/// first, group defaults appended). Overlay merging must give the +/// first-listed profile the highest precedence among profiles. +pub fn resolve_active_profiles( + inputs: &ProfileResolutionInputs, + diagnostics: &mut Vec, +) -> Vec { + // Explicit selection: the first source that applies wins outright + // (Q1 parity: `--profile` *replaces* `QUARTO_PROFILE`, etc.). A + // source that was explicitly given but yields no usable names + // still counts as "applied" — with a Q-5-21 error — so a broken + // selection never silently falls through to a different one. + let explicit: Vec = if let Some(cli) = inputs.cli { + parse_explicit_source(&cli.join(","), ProfileSource::CliArg, diagnostics) + } else if let Some(env) = nonempty(inputs.env_var) { + parse_explicit_source(env, ProfileSource::EnvVar, diagnostics) + } else if let Some(env_file) = nonempty(inputs.env_file) { + parse_explicit_source(env_file, ProfileSource::EnvironmentFile, diagnostics) + } else if !inputs.local_default.is_empty() { + named(inputs.local_default, ProfileSource::LocalConfigDefault) + } else if !inputs.config.default.is_empty() { + named(&inputs.config.default, ProfileSource::ConfigDefault) + } else { + Vec::new() + }; + + // Group expansion (groups come pre-validated and non-empty from + // extract_profile_config): every group must have an active + // member; otherwise its first member is appended *after* the + // explicit selection, giving it lower overlay precedence under + // first-listed-wins. + let mut active = explicit; + for group in &inputs.config.groups { + if !group + .iter() + .any(|member| active.iter().any(|a| &a.name == member)) + { + active.push(ActiveProfile { + name: group[0].clone(), + source: ProfileSource::GroupDefault, + }); + } + } + active +} + +/// Treat an empty string as an unset variable (`QUARTO_PROFILE= q2 +/// render` unsets), unlike a separator-only string, which is +/// "set but empty" and an error in [`parse_explicit_source`]. +fn nonempty(s: Option<&str>) -> Option<&str> { + s.filter(|s| !s.is_empty()) +} + +/// Parse one explicit selection string (CLI or environment), validate +/// each name (Q-5-21, span-less — these names were not written in +/// YAML), and error when the selection was given but names out empty. +fn parse_explicit_source( + raw: &str, + source: ProfileSource, + diagnostics: &mut Vec, +) -> Vec { + let names = parse_profile_string(raw); + if names.is_empty() { + diagnostics.push( + DiagnosticMessageBuilder::error("Empty project profile selection") + .with_code("Q-5-21") + .problem(format!( + "{} was given but contains no profile names. To render with \ + no profiles active, omit it entirely.", + source.describe() + )) + .build(), + ); + return Vec::new(); + } + names + .into_iter() + .filter(|name| validate_profile_name_diagnosed(name, None, source.describe(), diagnostics)) + .map(|name| ActiveProfile { name, source }) + .collect() +} + +/// Wrap already-validated default names with their source. +fn named(names: &[String], source: ProfileSource) -> Vec { + names + .iter() + .map(|name| ActiveProfile { + name: name.clone(), + source, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use quarto_error_reporting::DiagnosticKind; + + fn config_value_from_yaml(yaml: &str) -> ConfigValue { + use pampa::pandoc::yaml_to_config_value; + use pampa::utils::diagnostic_collector::DiagnosticCollector; + use quarto_config::InterpretationContext; + let parsed = quarto_yaml::parse_file(yaml, "_quarto.yml").expect("valid yaml"); + let mut diagnostics = DiagnosticCollector::new(); + yaml_to_config_value( + parsed, + InterpretationContext::ProjectConfig, + &mut diagnostics, + ) + } + + fn names(active: &[ActiveProfile]) -> Vec<&str> { + active.iter().map(|p| p.name.as_str()).collect() + } + + fn errors(diags: &[DiagnosticMessage]) -> Vec<&DiagnosticMessage> { + diags + .iter() + .filter(|d| d.kind == DiagnosticKind::Error) + .collect() + } + + fn codes(diags: &[DiagnosticMessage]) -> Vec { + diags + .iter() + .filter_map(|d| d.code.clone()) + .collect::>() + } + + // ── parse_profile_string ──────────────────────────────────────── + + #[test] + fn parse_splits_on_commas() { + assert_eq!(parse_profile_string("a,b"), vec!["a", "b"]); + } + + #[test] + fn parse_splits_on_spaces() { + // Q1 parity: /[ ,]+/ — spaces separate too. + assert_eq!(parse_profile_string("a b"), vec!["a", "b"]); + } + + #[test] + fn parse_collapses_separator_runs_and_trims_edges() { + // Q1 returns ["", "a", "b"] for " a,b" (a real bug we fix): + // edge separators must not produce empty names. + assert_eq!(parse_profile_string(" a,, b , c "), vec!["a", "b", "c"]); + } + + #[test] + fn parse_colon_is_not_a_separator() { + // Q1 parity: "a:b" is one (about-to-be-rejected) name, not two. + assert_eq!(parse_profile_string("a:b"), vec!["a:b"]); + } + + #[test] + fn parse_empty_and_separator_only_yield_no_names() { + assert!(parse_profile_string("").is_empty()); + assert!(parse_profile_string(" , ,").is_empty()); + } + + #[test] + fn parse_drops_duplicates_first_occurrence_wins() { + // Divergence from Q1 (which merges `_quarto-a.yml` twice for + // "a,b,a"): duplicates are dropped, order of first + // occurrence preserved. + assert_eq!(parse_profile_string("a,b,a"), vec!["a", "b"]); + } + + // ── is_valid_profile_name ─────────────────────────────────────── + + #[test] + fn valid_names_accepted() { + for name in [ + "production", + "dev2", + "a", + "advanced-docs", + "v1.2", + "a_b", + "A", + ] { + assert!(is_valid_profile_name(name), "{name:?} must be valid"); + } + } + + #[test] + fn invalid_names_rejected() { + for name in [ + "", // empty + ".hidden", // leading dot (dotfile) + "-x", // leading dash (option-like) + "_x", // leading underscore (first char must be alnum) + "a/b", // path separator + "a\\b", // path separator (windows) + "a:b", // colon (Q1 users expecting a separator) + "a b", // whitespace (unreachable via parse, reachable via YAML) + "café", // non-ASCII + ] { + assert!(!is_valid_profile_name(name), "{name:?} must be invalid"); + } + } + + // ── extract_profile_config: base config ───────────────────────── + + #[test] + fn extract_absent_key_is_default_and_silent() { + let mut meta = config_value_from_yaml("project:\n type: website\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config, ProjectProfileConfig::default()); + assert!(diags.is_empty()); + assert!(meta.get("project").is_some(), "other keys untouched"); + } + + #[test] + fn extract_string_default_normalizes_to_one_element() { + let mut meta = config_value_from_yaml("profile:\n default: dev\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.default, vec!["dev"]); + assert!(config.groups.is_empty()); + assert!(diags.is_empty()); + } + + #[test] + fn extract_list_default_preserves_order() { + let mut meta = config_value_from_yaml("profile:\n default: [advanced, production]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.default, vec!["advanced", "production"]); + assert!(diags.is_empty()); + } + + #[test] + fn extract_strips_profile_key_from_metadata() { + let mut meta = + config_value_from_yaml("project:\n type: website\nprofile:\n default: dev\n"); + let mut diags = Vec::new(); + extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert!( + meta.get("profile").is_none(), + "profile: must be stripped so downstream consumers never see it" + ); + assert!(meta.get("project").is_some(), "other keys survive"); + } + + #[test] + fn extract_flat_group_is_single_group() { + let mut meta = config_value_from_yaml("profile:\n group: [basic, advanced]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.groups, vec![vec!["basic", "advanced"]]); + assert!(diags.is_empty()); + } + + #[test] + fn extract_nested_groups() { + let mut meta = config_value_from_yaml("profile:\n group:\n - [a, b]\n - [c, d]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.groups, vec![vec!["a", "b"], vec!["c", "d"]]); + assert!(diags.is_empty()); + } + + #[test] + fn extract_mixed_shape_group_is_q_5_20_error_with_span() { + // Q1 silently yields ZERO groups for [a, [b, c]]; we error. + let mut meta = config_value_from_yaml("profile:\n group:\n - a\n - [b, c]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert!(config.groups.is_empty()); + let errs = errors(&diags); + assert_eq!(errs.len(), 1, "got: {:?}", codes(&diags)); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-20")); + assert!( + errs[0].location.is_some(), + "shape errors must carry the YAML span" + ); + } + + #[test] + fn extract_unknown_key_under_profile_is_q_5_20_error() { + // Q1 has a closed schema here; Q2 has no schema layer, so + // this closed-object check is explicit. + let mut meta = config_value_from_yaml("profile:\n default: dev\n defualt: prod\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.default, vec!["dev"], "valid keys still honored"); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-20")); + let text = errs[0].to_text(None); + assert!( + text.contains("defualt"), + "must name the unknown key: {text}" + ); + assert!(errs[0].location.is_some()); + } + + #[test] + fn extract_non_string_default_entry_is_q_5_20_error() { + let mut meta = config_value_from_yaml("profile:\n default: [1, dev]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + // The malformed entry is dropped, the valid one kept; the + // error aborts the render upstream anyway. + assert_eq!(config.default, vec!["dev"]); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-20")); + assert!(errs[0].location.is_some()); + } + + #[test] + fn extract_non_map_profile_value_is_q_5_20_error_and_stripped() { + // Q1: `ld.isObject` fails → silently ignored. We error. + let mut meta = config_value_from_yaml("profile: dev\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config, ProjectProfileConfig::default()); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-20")); + assert!( + meta.get("profile").is_none(), + "stripped even when malformed" + ); + } + + #[test] + fn extract_invalid_name_in_default_is_q_5_21_error_with_span() { + let mut meta = config_value_from_yaml("profile:\n default: bad/name\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert!(config.default.is_empty()); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-21")); + assert!(errs[0].location.is_some()); + } + + #[test] + fn extract_invalid_name_in_group_is_q_5_21_error() { + let mut meta = config_value_from_yaml("profile:\n group: [ok, .bad]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + // A group with an invalid member is dropped wholesale: its + // first-member-default semantics can't be trusted anymore. + assert!(config.groups.is_empty()); + assert_eq!( + codes(&errors(&diags).into_iter().cloned().collect::>()), + vec!["Q-5-21"] + ); + } + + #[test] + fn extract_empty_group_is_q_5_20_error() { + // An empty group has no first member to use as default. + let mut meta = config_value_from_yaml("profile:\n group:\n - []\n - [a, b]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::BaseConfig, + "_quarto.yml", + &mut diags, + ); + assert_eq!(config.groups, vec![vec!["a", "b"]], "valid group kept"); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-20")); + } + + // ── extract_profile_config: local config / overlay sites ──────── + + #[test] + fn extract_local_config_honors_default_only() { + let mut meta = config_value_from_yaml("profile:\n default: dev\n group: [a, b]\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::LocalConfig, + "_quarto.yml.local", + &mut diags, + ); + assert_eq!(config.default, vec!["dev"]); + assert!( + config.groups.is_empty(), + "groups are base-config-only (Q1 parity)" + ); + assert_eq!(diags.len(), 1, "got: {:?}", codes(&diags)); + assert_eq!(diags[0].kind, DiagnosticKind::Warning); + assert_eq!(diags[0].code.as_deref(), Some("Q-5-22")); + let text = diags[0].to_text(None); + assert!( + text.contains("_quarto.yml.local"), + "warning must name the file: {text}" + ); + } + + #[test] + fn extract_overlay_profile_key_is_q_5_22_warning_and_inert() { + let mut meta = config_value_from_yaml("profile:\n default: dev\nfoo: 1\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::Overlay, + "_quarto-prod.yml", + &mut diags, + ); + assert_eq!(config, ProjectProfileConfig::default(), "nothing honored"); + assert!(meta.get("profile").is_none(), "stripped from the overlay"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].kind, DiagnosticKind::Warning); + assert_eq!(diags[0].code.as_deref(), Some("Q-5-22")); + let text = diags[0].to_text(None); + assert!(text.contains("_quarto-prod.yml"), "got: {text}"); + } + + #[test] + fn extract_overlay_without_profile_key_is_silent() { + let mut meta = config_value_from_yaml("format:\n html:\n toc: true\n"); + let mut diags = Vec::new(); + let config = extract_profile_config( + &mut meta, + ProfileKeySite::Overlay, + "_quarto-prod.yml", + &mut diags, + ); + assert_eq!(config, ProjectProfileConfig::default()); + assert!(diags.is_empty()); + } + + // ── resolve_active_profiles ───────────────────────────────────── + + fn resolve(inputs: &ProfileResolutionInputs) -> (Vec, Vec) { + let mut diags = Vec::new(); + let active = resolve_active_profiles(inputs, &mut diags); + (active, diags) + } + + #[test] + fn resolve_nothing_is_empty_and_silent() { + let config = ProjectProfileConfig::default(); + let (active, diags) = resolve(&ProfileResolutionInputs { + config: &config, + ..Default::default() + }); + assert!(active.is_empty()); + assert!(diags.is_empty()); + } + + #[test] + fn resolve_cli_beats_env() { + let config = ProjectProfileConfig::default(); + let cli = vec!["a".to_string()]; + let (active, diags) = resolve(&ProfileResolutionInputs { + cli: Some(&cli), + env_var: Some("b"), + config: &config, + ..Default::default() + }); + assert_eq!( + names(&active), + vec!["a"], + "--profile replaces QUARTO_PROFILE" + ); + assert_eq!(active[0].source, ProfileSource::CliArg); + assert!(diags.is_empty()); + } + + #[test] + fn resolve_env_beats_env_file_beats_local_beats_config_default() { + let config = ProjectProfileConfig { + default: vec!["d".to_string()], + groups: Vec::new(), + }; + let local = vec!["c".to_string()]; + + let (active, _) = resolve(&ProfileResolutionInputs { + env_var: Some("a"), + env_file: Some("b"), + local_default: &local, + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["a"]); + assert_eq!(active[0].source, ProfileSource::EnvVar); + + let (active, _) = resolve(&ProfileResolutionInputs { + env_file: Some("b"), + local_default: &local, + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["b"]); + assert_eq!(active[0].source, ProfileSource::EnvironmentFile); + + let (active, _) = resolve(&ProfileResolutionInputs { + local_default: &local, + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["c"]); + assert_eq!(active[0].source, ProfileSource::LocalConfigDefault); + + let (active, _) = resolve(&ProfileResolutionInputs { + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["d"]); + assert_eq!(active[0].source, ProfileSource::ConfigDefault); + } + + #[test] + fn resolve_cli_values_split_and_combine() { + // Both `--profile a,b --profile c` and `--profile "a b"` work. + let config = ProjectProfileConfig::default(); + let cli = vec!["a,b".to_string(), "c".to_string()]; + let (active, diags) = resolve(&ProfileResolutionInputs { + cli: Some(&cli), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["a", "b", "c"]); + assert!(diags.is_empty()); + } + + #[test] + fn resolve_explicitly_empty_cli_is_q_5_21_error() { + let config = ProjectProfileConfig::default(); + let cli = vec![String::new()]; + let (active, diags) = resolve(&ProfileResolutionInputs { + cli: Some(&cli), + config: &config, + ..Default::default() + }); + assert!(active.is_empty()); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-21")); + } + + #[test] + fn resolve_separator_only_env_var_is_q_5_21_error() { + // QUARTO_PROFILE=" , " is set-but-empty: an error, not a + // silent no-op (divergence from Q1, which yields ["",""]). + let config = ProjectProfileConfig::default(); + let (active, diags) = resolve(&ProfileResolutionInputs { + env_var: Some(" , "), + config: &config, + ..Default::default() + }); + assert!(active.is_empty()); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-21")); + } + + #[test] + fn resolve_empty_string_env_var_is_unset() { + // `QUARTO_PROFILE= q2 render` must mean "no selection" and + // fall through to defaults, matching shell conventions. + let config = ProjectProfileConfig { + default: vec!["d".to_string()], + groups: Vec::new(), + }; + let (active, diags) = resolve(&ProfileResolutionInputs { + env_var: Some(""), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["d"]); + assert!(diags.is_empty()); + } + + #[test] + fn resolve_invalid_cli_name_is_q_5_21_error() { + let config = ProjectProfileConfig::default(); + let cli = vec!["good,bad/name".to_string()]; + let (active, diags) = resolve(&ProfileResolutionInputs { + cli: Some(&cli), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["good"], "valid names still resolve"); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + assert_eq!(errs[0].code.as_deref(), Some("Q-5-21")); + let text = errs[0].to_text(None); + assert!(text.contains("bad/name"), "must name the offender: {text}"); + } + + #[test] + fn resolve_colon_name_hints_about_separators() { + let config = ProjectProfileConfig::default(); + let (_, diags) = resolve(&ProfileResolutionInputs { + env_var: Some("a:b"), + config: &config, + ..Default::default() + }); + let errs = errors(&diags); + assert_eq!(errs.len(), 1); + let text = errs[0].to_text(None); + assert!( + text.contains("comma"), + "a colon-separated list deserves a targeted hint: {text}" + ); + } + + #[test] + fn resolve_group_appends_first_member_when_none_active() { + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![vec!["basic".to_string(), "advanced".to_string()]], + }; + let (active, diags) = resolve(&ProfileResolutionInputs { + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["basic"]); + assert_eq!(active[0].source, ProfileSource::GroupDefault); + assert!(diags.is_empty()); + } + + #[test] + fn resolve_group_satisfied_by_explicit_selection() { + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![vec!["basic".to_string(), "advanced".to_string()]], + }; + let (active, _) = resolve(&ProfileResolutionInputs { + env_var: Some("advanced"), + config: &config, + ..Default::default() + }); + assert_eq!( + names(&active), + vec!["advanced"], + "no group default appended" + ); + } + + #[test] + fn resolve_group_default_appends_after_explicit() { + // Appended AFTER explicit selections → lower overlay + // precedence under first-listed-wins. + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![vec!["fmt-a".to_string(), "fmt-b".to_string()]], + }; + let (active, _) = resolve(&ProfileResolutionInputs { + env_var: Some("production"), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["production", "fmt-a"]); + assert_eq!(active[1].source, ProfileSource::GroupDefault); + } + + #[test] + fn resolve_multiple_groups_each_contribute() { + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![ + vec!["a1".to_string(), "a2".to_string()], + vec!["b1".to_string(), "b2".to_string()], + ], + }; + let (active, _) = resolve(&ProfileResolutionInputs { + env_var: Some("b2"), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["b2", "a1"]); + } + + #[test] + fn resolve_groups_apply_even_with_cli_selection() { + // Q1 parity: group invariants hold regardless of how the + // explicit selection was made. + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![vec!["basic".to_string(), "advanced".to_string()]], + }; + let cli = vec!["production".to_string()]; + let (active, _) = resolve(&ProfileResolutionInputs { + cli: Some(&cli), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["production", "basic"]); + } + + #[test] + fn resolve_config_default_and_groups_compose() { + // default supplies the explicit set; groups still enforced. + let config = ProjectProfileConfig { + default: vec!["docs".to_string()], + groups: vec![vec!["basic".to_string(), "advanced".to_string()]], + }; + let (active, _) = resolve(&ProfileResolutionInputs { + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["docs", "basic"]); + } + + #[test] + fn resolve_dedups_across_sources() { + let config = ProjectProfileConfig { + default: Vec::new(), + groups: vec![vec!["a".to_string(), "b".to_string()]], + }; + let (active, _) = resolve(&ProfileResolutionInputs { + env_var: Some("a,a"), + config: &config, + ..Default::default() + }); + assert_eq!(names(&active), vec!["a"], "dup dropped, group satisfied"); + } + + // ── error catalog registration ────────────────────────────────── + + #[test] + fn project_profile_error_codes_are_registered_in_catalog() { + for code in ["Q-5-19", "Q-5-20", "Q-5-21", "Q-5-22"] { + assert!( + quarto_error_catalog::ERROR_CATALOG.get(code).is_some(), + "{code} must be registered in the quarto-error-catalog" + ); + } + } +} diff --git a/crates/quarto-core/src/project/render_scripts.rs b/crates/quarto-core/src/project/render_scripts.rs index eee39f592..396424bbc 100644 --- a/crates/quarto-core/src/project/render_scripts.rs +++ b/crates/quarto-core/src/project/render_scripts.rs @@ -271,6 +271,11 @@ mod exec { /// bind that file — not `_quarto.yml` — to the resolved /// FileId (bd-m6wmztln). pub extension_manifest_paths: &'a [PathBuf], + /// Project-profile overlay / `_quarto.yml.local` paths + /// ([`crate::project::ProjectConfig::profile_config_paths`], + /// bd-fu16z22k): a script entry written in an overlay carries + /// that file's FileId, same binding discipline as manifests. + pub profile_config_paths: &'a [PathBuf], /// True iff the whole project is being rendered. Exported as /// `QUARTO_PROJECT_RENDER_ALL=1`; the variable is *absent* /// otherwise (not `"0"`), matching Q1. @@ -288,6 +293,12 @@ mod exec { /// Applied before the `QUARTO_PROJECT_*` variables, so those /// win any collision. pub project_env: &'a [(String, String)], + /// Normalized active project-profile list for the child's + /// `QUARTO_PROFILE` + /// ([`crate::project::project_profile::quarto_profile_env_value`], + /// bd-fu16z22k). Applied unconditionally — overrides an + /// inherited `QUARTO_PROFILE`, unlike `project_env` pairs. + pub quarto_profile: Option, } impl RenderScriptsContext<'_> { @@ -316,6 +327,9 @@ mod exec { if self.render_all { env.push(("QUARTO_PROJECT_RENDER_ALL", "1".to_string())); } + if let Some(quarto_profile) = &self.quarto_profile { + env.push(("QUARTO_PROFILE", quarto_profile.clone())); + } env } } @@ -573,6 +587,7 @@ mod exec { let candidates = ctx .config_path .into_iter() + .chain(ctx.profile_config_paths.iter().map(PathBuf::as_path)) .chain(ctx.extension_manifest_paths.iter().map(PathBuf::as_path)); let matched = crate::config_sources::bind_config_source(&mut source_context, info, candidates); @@ -928,6 +943,8 @@ mod tests { output_dir: project_dir, config_path: None, extension_manifest_paths: &[], + profile_config_paths: &[], + quarto_profile: None, render_all: true, quiet: true, file_count: 1, @@ -941,6 +958,45 @@ mod tests { ); } + /// A script sees the normalized `QUARTO_PROFILE` from + /// `RenderScriptsContext::quarto_profile` (bd-fu16z22k) — applied + /// via `cmd.env`, so it overrides anything inherited. + #[cfg(unix)] + #[test] + fn scripts_receive_quarto_profile() { + use quarto_source_map::By; + + let dir = tempfile::tempdir().unwrap(); + let project_dir = dir.path(); + let out_path = project_dir.join("profile-out.txt"); + + let script = RenderScript { + command: format!( + "sh -c \"printf %s $QUARTO_PROFILE > {}\"", + out_path.display() + ), + source_info: SourceInfo::generated(By::unknown()), + }; + let ctx = RenderScriptsContext { + project_dir, + output_dir: project_dir, + config_path: None, + extension_manifest_paths: &[], + profile_config_paths: &[], + quarto_profile: Some("advanced,production".to_string()), + render_all: true, + quiet: true, + file_count: 1, + project_env: &[], + }; + run_render_scripts(ScriptPhase::PreRender, &[script], &ctx, &[]) + .expect("script should succeed"); + assert_eq!( + std::fs::read_to_string(&out_path).expect("script wrote the file"), + "advanced,production" + ); + } + // ── error catalog registration ────────────────────────────────── #[test] diff --git a/crates/quarto-core/src/project_resources.rs b/crates/quarto-core/src/project_resources.rs index eb2077cc4..eb7087a08 100644 --- a/crates/quarto-core/src/project_resources.rs +++ b/crates/quarto-core/src/project_resources.rs @@ -925,6 +925,12 @@ pub fn resource_error_to_doc_parse_error( let mut source_context = SourceContext::new(); let candidates = std::iter::once((FileId(0), doc_source)) .chain(config.config_path.as_deref().map(|p| (hash(p), p))) + .chain( + config + .profile_config_paths + .iter() + .map(|p| (hash(p), p.as_path())), + ) .chain( config .extension_manifest_paths @@ -970,6 +976,7 @@ pub fn resource_error_to_config_parse_error( .config_path .as_deref() .into_iter() + .chain(config.profile_config_paths.iter().map(PathBuf::as_path)) .chain(config.extension_manifest_paths.iter().map(PathBuf::as_path)); let matched = crate::config_sources::bind_config_source( &mut source_context, diff --git a/crates/quarto-core/src/stage/context.rs b/crates/quarto-core/src/stage/context.rs index e76c65372..70b6fa1c2 100644 --- a/crates/quarto-core/src/stage/context.rs +++ b/crates/quarto-core/src/stage/context.rs @@ -254,12 +254,16 @@ impl StageContext { let variables = load_project_variables(runtime.as_ref(), &project, &mut startup_diagnostics); - // Active profiles are always empty until bd-ev8mk1rp lands - // render-profile support. + let active_profile_names: Vec = project + .config + .active_config_profiles + .iter() + .map(|p| p.name.clone()) + .collect(); let project_env = crate::project::environment::load_project_environment( runtime.as_ref(), &project, - &[], + &active_profile_names, &mut startup_diagnostics, ); diff --git a/crates/quarto-core/src/stage/stages/ast_transforms.rs b/crates/quarto-core/src/stage/stages/ast_transforms.rs index 481c794ad..f9330c711 100644 --- a/crates/quarto-core/src/stage/stages/ast_transforms.rs +++ b/crates/quarto-core/src/stage/stages/ast_transforms.rs @@ -143,6 +143,9 @@ impl PipelineStage for AstTransformsStage { ctx.format.target_format.clone(), ctx.variables.clone(), ctx.project_env.clone(), + crate::project::project_profile::quarto_profile_env_value( + &ctx.project.config.active_config_profiles, + ), ), _ => build_transform_pipeline( shortcode_paths, @@ -151,6 +154,9 @@ impl PipelineStage for AstTransformsStage { ctx.format.target_format.clone(), ctx.variables.clone(), ctx.project_env.clone(), + crate::project::project_profile::quarto_profile_env_value( + &ctx.project.config.active_config_profiles, + ), ), }; &jit_pipeline diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index e5856954f..38f48c4ed 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -639,6 +639,11 @@ fn theme_error_candidates( candidates.push((hash(p), p.to_path_buf())); } candidates.push((quarto_source_map::FileId(0), ctx.document.input.clone())); + // Project-profile overlays / `_quarto.yml.local` (bd-fu16z22k): + // a merged `theme:` can be written in any of them. + for p in &ctx.project.config.profile_config_paths { + candidates.push((hash(p), p.clone())); + } for p in &ctx.project.config.extension_manifest_paths { candidates.push((hash(p), p.clone())); } diff --git a/crates/quarto-core/src/stage/stages/engine_execution.rs b/crates/quarto-core/src/stage/stages/engine_execution.rs index 611b04dc1..c0fdb8619 100644 --- a/crates/quarto-core/src/stage/stages/engine_execution.rs +++ b/crates/quarto-core/src/stage/stages/engine_execution.rs @@ -385,9 +385,23 @@ impl PipelineStage for EngineExecutionStage { }) .with_engine_config(engine_config) .with_source_info(qmd_source_info, source_context_arc.clone()) - .with_project_env(crate::project::environment::env_for_subprocess( - &ctx.project_env, - )); + .with_project_env({ + let mut pairs = crate::project::environment::env_for_subprocess(&ctx.project_env); + // QUARTO_PROFILE is applied unconditionally (it may + // override an inherited value): engine cells must see + // the normalized active set, exactly as Q1's env + // write-back guaranteed (bd-fu16z22k). + if let Some(value) = crate::project::project_profile::quarto_profile_env_value( + &ctx.project.config.active_config_profiles, + ) { + pairs.retain(|(k, _)| k != crate::project::project_profile::QUARTO_PROFILE_VAR); + pairs.push(( + crate::project::project_profile::QUARTO_PROFILE_VAR.to_string(), + value, + )); + } + pairs + }); trace_event!(ctx, EventLevel::Info, "executing engine: {}", engine.name()); let mut result = engine diff --git a/crates/quarto-core/src/stage/stages/metadata_merge.rs b/crates/quarto-core/src/stage/stages/metadata_merge.rs index 403c96a16..6944fcd00 100644 --- a/crates/quarto-core/src/stage/stages/metadata_merge.rs +++ b/crates/quarto-core/src/stage/stages/metadata_merge.rs @@ -314,6 +314,14 @@ impl PipelineStage for MetadataMergeStage { if let Some(config_path) = ctx.project.config.config_path.as_deref() { register(config_path); } + // Project-profile overlays (`_quarto-.yml`) and + // `_quarto.yml.local` (bd-fu16z22k): values merged from + // these layers keep their own filename-hash FileIds, so + // the files must be registered like `_quarto.yml` itself + // or overlay-anchored diagnostics render span-less. + for path in &ctx.project.config.profile_config_paths { + register(path); + } for (path, _) in &dir_layer_entries { register(path); } diff --git a/crates/quarto-core/src/transforms/conditional_content.rs b/crates/quarto-core/src/transforms/conditional_content.rs new file mode 100644 index 000000000..893a83a16 --- /dev/null +++ b/crates/quarto-core/src/transforms/conditional_content.rs @@ -0,0 +1,633 @@ +/* + * conditional_content.rs + * Copyright (c) 2026 Posit, PBC + * + * Conditional content: .content-visible / .content-hidden + * (bd-fu16z22k, Phase 4). + */ + +//! Conditional content — the Q2 port of Quarto 1's +//! `content-hidden.lua` custom-node filter. +//! +//! Divs, Spans, and CodeBlocks carrying `.content-visible` or +//! `.content-hidden` are kept or removed based on `when-format` / +//! `unless-format`, `when-profile` / `unless-profile` (project +//! profiles, bd-fu16z22k), and `when-meta` / `unless-meta` +//! attributes: +//! +//! - condition kinds **AND** together (`when-format="html" +//! when-profile="prod"` needs both); +//! - comma/space-separated values within one condition **OR** — a q2 +//! extension; Q1 matches the attribute value literally, so +//! `when-profile="a,b"` silently never matched there; +//! - `unless-*` negates its kind; +//! - `.content-visible` with no conditions is always visible, +//! `.content-hidden` with no conditions always hidden; +//! - surviving elements keep their classes but lose the condition +//! attributes (Q1's `clearHiddenVisibleAttributes`). +//! +//! Semantics notes: +//! - `when-format` uses the same alias table as Lua's +//! `quarto.doc.is_format` ([`pampa::lua::quarto_doc::is_format_match`]), +//! matched against the canonical Pandoc format +//! ([`crate::format::lua_format_for`]) so preview pseudo-formats +//! behave like render. +//! - `when-meta` resolves a dotted path in the document's **merged** +//! metadata (the transform runs after `MetadataMergeStage`, so +//! profile overlays are visible) with Q1 truthiness: present and +//! not `false` ⇒ true. +//! - The transform runs first in the Normalization phase — before +//! shortcode resolution, so hidden content cannot emit spurious +//! shortcode warnings, and long before crossref numbering, so a +//! hidden float never consumes a number. (Engine cells inside +//! hidden blocks still *execute* — engines run in an earlier +//! pipeline stage; Q1 behaves the same way.) +//! +//! Strictness (divergence from Q1, which is silent): unknown +//! `when-*` / `unless-*` attributes on a marker element, and elements +//! carrying *both* marker classes (treated as hidden), warn with +//! **Q-2-42**. + +use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder}; +use quarto_pandoc_types::pandoc::Pandoc; +use quarto_pandoc_types::{Attr, Block, ConfigValue, Inline}; + +use crate::render::RenderContext; +use crate::transform::{AstTransform, TransformPhase}; + +const VISIBLE_CLASS: &str = "content-visible"; +const HIDDEN_CLASS: &str = "content-hidden"; + +const CONDITION_KEYS: [&str; 6] = [ + "when-format", + "unless-format", + "when-profile", + "unless-profile", + "when-meta", + "unless-meta", +]; + +/// See the module docs. Registered first in the Normalization phase +/// of `build_transform_pipeline`. +pub struct ConditionalContentTransform; + +impl ConditionalContentTransform { + pub fn new() -> Self { + Self + } +} + +impl Default for ConditionalContentTransform { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait(?Send)] +impl AstTransform for ConditionalContentTransform { + fn name(&self) -> &str { + "conditional-content" + } + + fn phase(&self) -> TransformPhase { + TransformPhase::Normalization + } + + async fn transform(&self, ast: &mut Pandoc, ctx: &mut RenderContext) -> crate::Result<()> { + let lua_format = crate::format::lua_format_for(&ctx.format.target_format).to_string(); + let active: Vec<&str> = ctx + .project + .config + .active_config_profiles + .iter() + .map(|p| p.name.as_str()) + .collect(); + let mut diagnostics = Vec::new(); + { + let env = ConditionEnv { + format: &lua_format, + active_profiles: &active, + meta: &ast.meta, + diagnostics: &mut diagnostics, + }; + let mut walker = Walker { env }; + walker.filter_blocks(&mut ast.blocks); + } + ctx.diagnostics.extend(diagnostics); + Ok(()) + } +} + +/// Everything a condition evaluation can see. +struct ConditionEnv<'a> { + /// Canonical Pandoc format (`lua_format_for`), for the alias table. + format: &'a str, + /// Active project-profile names, activation order. + active_profiles: &'a [&'a str], + /// The document's merged metadata. + meta: &'a ConfigValue, + diagnostics: &'a mut Vec, +} + +struct Walker<'a> { + env: ConditionEnv<'a>, +} + +/// What to do with a marker element. +enum Verdict { + /// Not a conditional element — leave untouched. + NotConditional, + /// Keep it, after stripping the condition attributes. + Keep, + /// Remove it entirely. + Remove, +} + +impl Walker<'_> { + /// Evaluate an element's marker classes + condition attributes. + fn verdict(&mut self, attr: &Attr, source_info: &quarto_source_map::SourceInfo) -> Verdict { + let visible_marker = attr.1.iter().any(|c| c == VISIBLE_CLASS); + let hidden_marker = attr.1.iter().any(|c| c == HIDDEN_CLASS); + if !visible_marker && !hidden_marker { + return Verdict::NotConditional; + } + if visible_marker && hidden_marker { + self.env.diagnostics.push( + DiagnosticMessageBuilder::warning( + "Element is both `.content-visible` and `.content-hidden`", + ) + .with_code("Q-2-42") + .problem( + "An element cannot carry both marker classes; it is treated as \ + `.content-hidden`.", + ) + .with_location(source_info.clone()) + .build(), + ); + } + + // Unknown `when-*` / `unless-*` spellings are probably typos. + for key in attr.2.keys() { + if (key.starts_with("when-") || key.starts_with("unless-")) + && !CONDITION_KEYS.contains(&key.as_str()) + { + self.env.diagnostics.push( + DiagnosticMessageBuilder::warning(format!( + "Unknown conditional-content attribute `{key}`" + )) + .with_code("Q-2-42") + .problem(format!( + "`{key}` is not a recognized condition and is ignored. Supported \ + conditions: `when-format`, `unless-format`, `when-profile`, \ + `unless-profile`, `when-meta`, `unless-meta`." + )) + .with_location(source_info.clone()) + .build(), + ); + } + } + + let conditions_match = self.conditions_match(attr); + // Both markers present ⇒ hidden semantics (the safe reading). + let visible = if hidden_marker { + !conditions_match + } else { + conditions_match + }; + if visible { + Verdict::Keep + } else { + Verdict::Remove + } + } + + /// AND across condition kinds; OR across comma/space-separated + /// values within one condition; `unless-*` negates. No condition + /// attributes ⇒ vacuously true. + fn conditions_match(&self, attr: &Attr) -> bool { + #[derive(Clone, Copy)] + enum Kind { + Format, + Profile, + Meta, + } + let mut result = true; + for (key, value) in attr.2.iter() { + let (invert, kind) = match key.as_str() { + "when-format" => (false, Kind::Format), + "unless-format" => (true, Kind::Format), + "when-profile" => (false, Kind::Profile), + "unless-profile" => (true, Kind::Profile), + "when-meta" => (false, Kind::Meta), + "unless-meta" => (true, Kind::Meta), + _ => continue, + }; + let any = value + .split([',', ' ']) + .filter(|v| !v.is_empty()) + .any(|v| match kind { + Kind::Format => self.check_format(v), + Kind::Profile => self.check_profile(v), + Kind::Meta => self.check_meta(v), + }); + result = result && (invert != any); + } + result + } + + fn check_format(&self, query: &str) -> bool { + pampa::lua::quarto_doc::is_format_match(self.env.format, query) + } + + fn check_profile(&self, name: &str) -> bool { + self.env.active_profiles.contains(&name) + } + + /// Q1's `check_meta`: dotted-path lookup in the merged metadata; + /// truthy = present and not `false` (null counts as absent). + fn check_meta(&self, path: &str) -> bool { + let parts: Vec<&str> = path.split('.').collect(); + match self.env.meta.get_path(&parts) { + None => false, + Some(value) => { + if value.is_null() { + return false; + } + value.as_bool().unwrap_or(true) + } + } + } + + // ── recursion ─────────────────────────────────────────────────── + + fn filter_blocks(&mut self, blocks: &mut Vec) { + blocks.retain_mut(|block| self.keep_block(block)); + } + + /// Decide whether `block` survives; recurse into whatever content + /// it keeps. + fn keep_block(&mut self, block: &mut Block) -> bool { + match block { + Block::Div(div) => { + match self.verdict(&div.attr, &div.source_info) { + Verdict::Remove => return false, + Verdict::Keep => strip_condition_attrs(&mut div.attr, &mut div.attr_source), + Verdict::NotConditional => {} + } + self.filter_blocks(&mut div.content); + } + Block::CodeBlock(cb) => match self.verdict(&cb.attr, &cb.source_info) { + Verdict::Remove => return false, + Verdict::Keep => strip_condition_attrs(&mut cb.attr, &mut cb.attr_source), + Verdict::NotConditional => {} + }, + Block::Plain(b) => self.filter_inlines(&mut b.content), + Block::Paragraph(b) => self.filter_inlines(&mut b.content), + Block::Header(h) => self.filter_inlines(&mut h.content), + Block::LineBlock(lb) => { + for line in &mut lb.content { + self.filter_inlines(line); + } + } + Block::BlockQuote(bq) => self.filter_blocks(&mut bq.content), + Block::OrderedList(ol) => { + for item in &mut ol.content { + self.filter_blocks(item); + } + } + Block::BulletList(bl) => { + for item in &mut bl.content { + self.filter_blocks(item); + } + } + Block::DefinitionList(dl) => { + for (term, defs) in &mut dl.content { + self.filter_inlines(term); + for def in defs { + self.filter_blocks(def); + } + } + } + Block::Figure(fig) => { + self.filter_blocks(&mut fig.content); + if let Some(short) = &mut fig.caption.short { + self.filter_inlines(short); + } + if let Some(long) = &mut fig.caption.long { + self.filter_blocks(long); + } + } + Block::Table(table) => { + if let Some(short) = &mut table.caption.short { + self.filter_inlines(short); + } + if let Some(long) = &mut table.caption.long { + self.filter_blocks(long); + } + for row in &mut table.head.rows { + for cell in &mut row.cells { + self.filter_blocks(&mut cell.content); + } + } + for body in &mut table.bodies { + for row in &mut body.body { + for cell in &mut row.cells { + self.filter_blocks(&mut cell.content); + } + } + } + for row in &mut table.foot.rows { + for cell in &mut row.cells { + self.filter_blocks(&mut cell.content); + } + } + } + Block::Custom(custom) => { + for (_name, slot) in &mut custom.slots { + use quarto_pandoc_types::custom::Slot; + match slot { + Slot::Block(b) => { + // A slot holds exactly one block; removal + // isn't representable, so only recurse. + let _ = self.keep_block(b); + } + Slot::Blocks(bs) => self.filter_blocks(bs), + Slot::Inline(i) => { + let _ = self.keep_inline(i); + } + Slot::Inlines(is) => self.filter_inlines(is), + } + } + } + _ => {} + } + true + } + + fn filter_inlines(&mut self, inlines: &mut Vec) { + inlines.retain_mut(|inline| self.keep_inline(inline)); + } + + fn keep_inline(&mut self, inline: &mut Inline) -> bool { + match inline { + Inline::Span(span) => { + match self.verdict(&span.attr, &span.source_info) { + Verdict::Remove => return false, + Verdict::Keep => strip_condition_attrs(&mut span.attr, &mut span.attr_source), + Verdict::NotConditional => {} + } + self.filter_inlines(&mut span.content); + } + Inline::Emph(i) => self.filter_inlines(&mut i.content), + Inline::Underline(i) => self.filter_inlines(&mut i.content), + Inline::Strong(i) => self.filter_inlines(&mut i.content), + Inline::Strikeout(i) => self.filter_inlines(&mut i.content), + Inline::Superscript(i) => self.filter_inlines(&mut i.content), + Inline::Subscript(i) => self.filter_inlines(&mut i.content), + Inline::SmallCaps(i) => self.filter_inlines(&mut i.content), + Inline::Quoted(i) => self.filter_inlines(&mut i.content), + Inline::Cite(i) => self.filter_inlines(&mut i.content), + Inline::Link(i) => self.filter_inlines(&mut i.content), + Inline::Image(i) => self.filter_inlines(&mut i.content), + Inline::Note(note) => self.filter_blocks(&mut note.content), + _ => {} + } + true + } +} + +/// Remove the condition attributes from a surviving element, keeping +/// classes (including the marker class) — Q1's +/// `clearHiddenVisibleAttributes`. The parallel `AttrSourceInfo` +/// entries are removed in lockstep to preserve the +/// positional-alignment invariant (see `attr.rs`); on a preexisting +/// misalignment the source entries are cleared rather than guessed. +fn strip_condition_attrs(attr: &mut Attr, attr_source: &mut quarto_pandoc_types::AttrSourceInfo) { + let aligned = attr.2.len() == attr_source.attributes.len(); + if aligned { + let keep: Vec = attr + .2 + .keys() + .map(|k| !CONDITION_KEYS.contains(&k.as_str())) + .collect(); + let mut it = keep.iter(); + attr_source.attributes.retain(|_| *it.next().unwrap()); + } else { + attr_source.attributes.clear(); + } + attr.2.retain(|k, _| !CONDITION_KEYS.contains(&k.as_str())); +} + +#[cfg(test)] +mod tests { + use super::*; + use quarto_pandoc_types::AttrSourceInfo; + use quarto_pandoc_types::block::Div; + use quarto_source_map::{By, SourceInfo}; + + fn attr(classes: &[&str], kvs: &[(&str, &str)]) -> Attr { + ( + String::new(), + classes.iter().map(|c| c.to_string()).collect(), + kvs.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) + } + + fn div(classes: &[&str], kvs: &[(&str, &str)], text: &str) -> Block { + Block::Div(Div { + attr: attr(classes, kvs), + content: vec![Block::Plain(quarto_pandoc_types::block::Plain { + content: vec![Inline::Str(quarto_pandoc_types::inline::Str { + text: text.to_string(), + source_info: SourceInfo::generated(By::unknown()), + })], + source_info: SourceInfo::generated(By::unknown()), + })], + source_info: SourceInfo::generated(By::unknown()), + attr_source: AttrSourceInfo::empty(), + }) + } + + fn run( + blocks: &mut Vec, + format: &str, + active: &[&str], + meta: &ConfigValue, + ) -> Vec { + let mut diagnostics = Vec::new(); + let env = ConditionEnv { + format, + active_profiles: active, + meta, + diagnostics: &mut diagnostics, + }; + let mut walker = Walker { env }; + walker.filter_blocks(blocks); + diagnostics + } + + fn empty_meta() -> ConfigValue { + ConfigValue::new_map(vec![], SourceInfo::generated(By::unknown())) + } + + fn texts(blocks: &[Block]) -> String { + // Debug-format is a lazy but reliable way to see which + // Str contents survived. + format!("{blocks:?}") + } + + #[test] + fn visible_kept_when_profile_active_removed_otherwise() { + let meta = empty_meta(); + let mut blocks = vec![div(&["content-visible"], &[("when-profile", "adv")], "X")]; + run(&mut blocks, "html", &["adv"], &meta); + assert_eq!(blocks.len(), 1); + + let mut blocks = vec![div(&["content-visible"], &[("when-profile", "adv")], "X")]; + run(&mut blocks, "html", &[], &meta); + assert!(blocks.is_empty()); + } + + #[test] + fn hidden_inverts() { + let meta = empty_meta(); + let mut blocks = vec![div(&["content-hidden"], &[("when-profile", "adv")], "X")]; + run(&mut blocks, "html", &["adv"], &meta); + assert!(blocks.is_empty()); + + let mut blocks = vec![div(&["content-hidden"], &[("when-profile", "adv")], "X")]; + run(&mut blocks, "html", &[], &meta); + assert_eq!(blocks.len(), 1); + } + + #[test] + fn bare_markers() { + let meta = empty_meta(); + let mut blocks = vec![ + div(&["content-hidden"], &[], "H"), + div(&["content-visible"], &[], "V"), + ]; + run(&mut blocks, "html", &[], &meta); + assert_eq!(blocks.len(), 1); + assert!(texts(&blocks).contains('V')); + } + + #[test] + fn format_alias_matching() { + let meta = empty_meta(); + // revealjs is html-family: when-format="html" matches. + let mut blocks = vec![div(&["content-visible"], &[("when-format", "html")], "X")]; + run(&mut blocks, "revealjs", &[], &meta); + assert_eq!(blocks.len(), 1, "revealjs is an html alias"); + + let mut blocks = vec![div(&["content-visible"], &[("when-format", "pdf")], "X")]; + run(&mut blocks, "html", &[], &meta); + assert!(blocks.is_empty()); + } + + #[test] + fn kinds_and_together_values_or_within() { + let meta = empty_meta(); + let kvs = [("when-format", "html"), ("when-profile", "a,b")]; + let mut blocks = vec![div(&["content-visible"], &kvs, "X")]; + run(&mut blocks, "html", &["b"], &meta); + assert_eq!(blocks.len(), 1, "html AND (a OR b) holds"); + + let mut blocks = vec![div(&["content-visible"], &kvs, "X")]; + run(&mut blocks, "latex", &["b"], &meta); + assert!(blocks.is_empty(), "format leg fails"); + + let mut blocks = vec![div(&["content-visible"], &kvs, "X")]; + run(&mut blocks, "html", &["c"], &meta); + assert!(blocks.is_empty(), "profile leg fails"); + } + + #[test] + fn meta_truthiness() { + let meta = { + use pampa::pandoc::yaml_to_config_value; + use pampa::utils::diagnostic_collector::DiagnosticCollector; + use quarto_config::InterpretationContext; + let parsed = + quarto_yaml::parse_file("features:\n beta: true\n off: false\n", "_quarto.yml") + .expect("valid yaml"); + let mut diagnostics = DiagnosticCollector::new(); + yaml_to_config_value( + parsed, + InterpretationContext::ProjectConfig, + &mut diagnostics, + ) + }; + let case = |path: &str, meta: &ConfigValue| { + let mut blocks = vec![div(&["content-visible"], &[("when-meta", path)], "X")]; + run(&mut blocks, "html", &[], meta); + !blocks.is_empty() + }; + assert!(case("features.beta", &meta)); + assert!(!case("features.off", &meta), "explicit false is falsy"); + assert!(!case("features.missing", &meta)); + assert!(case("features", &meta), "a map is truthy"); + } + + #[test] + fn surviving_element_loses_condition_attrs_keeps_class() { + let meta = empty_meta(); + let mut blocks = vec![div( + &["content-visible", "keep-me"], + &[("when-profile", "adv"), ("data-x", "1")], + "X", + )]; + run(&mut blocks, "html", &["adv"], &meta); + let Block::Div(d) = &blocks[0] else { + panic!("div survives") + }; + assert!(d.attr.1.contains(&"content-visible".to_string())); + assert!(d.attr.1.contains(&"keep-me".to_string())); + assert!(!d.attr.2.contains_key("when-profile"), "stripped"); + assert!(d.attr.2.contains_key("data-x"), "unrelated attrs kept"); + } + + #[test] + fn both_markers_warn_and_hide() { + let meta = empty_meta(); + let mut blocks = vec![div(&["content-visible", "content-hidden"], &[], "X")]; + let diags = run(&mut blocks, "html", &[], &meta); + assert!(blocks.is_empty(), "hidden wins"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code.as_deref(), Some("Q-2-42")); + } + + #[test] + fn unknown_condition_attr_warns_once() { + let meta = empty_meta(); + let mut blocks = vec![div(&["content-visible"], &[("when-profil", "x")], "X")]; + let diags = run(&mut blocks, "html", &[], &meta); + assert_eq!(blocks.len(), 1, "unknown condition doesn't hide"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code.as_deref(), Some("Q-2-42")); + assert!(diags[0].to_text(None).contains("when-profil")); + } + + #[test] + fn nested_conditionals() { + let meta = empty_meta(); + let inner = div(&["content-visible"], &[("when-profile", "adv")], "INNER"); + let outer = Block::Div(Div { + attr: attr(&["content-visible"], &[("when-format", "html")]), + content: vec![inner], + source_info: SourceInfo::generated(By::unknown()), + attr_source: AttrSourceInfo::empty(), + }); + let mut blocks = vec![outer]; + run(&mut blocks, "html", &[], &meta); + assert_eq!(blocks.len(), 1, "outer survives"); + assert!(!texts(&blocks).contains("INNER"), "inner removed"); + } + + #[test] + fn error_code_is_registered_in_catalog() { + assert!(quarto_error_catalog::ERROR_CATALOG.get("Q-2-42").is_some()); + } +} diff --git a/crates/quarto-core/src/transforms/mod.rs b/crates/quarto-core/src/transforms/mod.rs index 0d128b397..696ff7a67 100644 --- a/crates/quarto-core/src/transforms/mod.rs +++ b/crates/quarto-core/src/transforms/mod.rs @@ -38,6 +38,7 @@ mod callout_resolve; mod categories_sidebar; mod code_block_generate; mod code_block_render; +mod conditional_content; mod config; mod config_markdown; mod crossref_index; @@ -94,6 +95,7 @@ pub use code_block_generate::{ resolve_default_copy_mode, }; pub use code_block_render::CodeBlockRenderTransform; +pub use conditional_content::ConditionalContentTransform; pub use config::{ AppendixStyle, ReferenceLocation, TitleBlockStyle, is_feature_disabled, resolve_website_bool, }; diff --git a/crates/quarto-core/src/transforms/shortcode_resolve.rs b/crates/quarto-core/src/transforms/shortcode_resolve.rs index b36fd3213..77e0953d6 100644 --- a/crates/quarto-core/src/transforms/shortcode_resolve.rs +++ b/crates/quarto-core/src/transforms/shortcode_resolve.rs @@ -187,11 +187,24 @@ fn positional_arg_to_string(arg: &ShortcodeArg) -> Option { /// project env map (loaded through the VFS) and fallbacks apply there. pub struct EnvShortcodeHandler { project_env: hashlink::LinkedHashMap, + /// Normalized active project-profile list for `QUARTO_PROFILE` + /// lookups (bd-fu16z22k). Checked BEFORE the real environment: + /// after `--profile b` replaced `QUARTO_PROFILE=a`, the shortcode + /// must resolve `b` — Q1 guaranteed this by writing the + /// normalized list back into the env; q2 never mutates its env, + /// so the handler carries the value as data instead. + quarto_profile: Option, } impl EnvShortcodeHandler { - pub fn new(project_env: hashlink::LinkedHashMap) -> Self { - Self { project_env } + pub fn new( + project_env: hashlink::LinkedHashMap, + quarto_profile: Option, + ) -> Self { + Self { + project_env, + quarto_profile, + } } } @@ -222,8 +235,10 @@ impl ShortcodeHandler for EnvShortcodeHandler { }); }; - let value = std::env::var(&name) - .ok() + let value = (name == crate::project::project_profile::QUARTO_PROFILE_VAR) + .then(|| self.quarto_profile.clone()) + .flatten() + .or_else(|| std::env::var(&name).ok()) .or_else(|| self.project_env.get(&name).cloned()) .or_else(|| { shortcode @@ -459,7 +474,7 @@ impl ShortcodeResolveTransform { /// Used in tests that don't need Lua support. pub fn new() -> Self { Self { - handlers: Self::builtin_handlers(None, hashlink::LinkedHashMap::new()), + handlers: Self::builtin_handlers(None, hashlink::LinkedHashMap::new(), None), lua_shortcode_paths: Vec::new(), extensions: Vec::new(), runtime: None, @@ -473,10 +488,11 @@ impl ShortcodeResolveTransform { fn builtin_handlers( variables: Option, project_env: hashlink::LinkedHashMap, + quarto_profile: Option, ) -> Vec> { vec![ Box::new(MetaShortcodeHandler), - Box::new(EnvShortcodeHandler::new(project_env)), + Box::new(EnvShortcodeHandler::new(project_env, quarto_profile)), Box::new(VarShortcodeHandler::new(variables)), ] } @@ -492,9 +508,10 @@ impl ShortcodeResolveTransform { target_format: String, variables: Option, project_env: hashlink::LinkedHashMap, + quarto_profile: Option, ) -> Self { Self { - handlers: Self::builtin_handlers(variables, project_env), + handlers: Self::builtin_handlers(variables, project_env, quarto_profile), lua_shortcode_paths, extensions, runtime: Some(runtime), @@ -2323,7 +2340,7 @@ mod tests { #[test] fn test_env_shortcode_handler_set_and_fallback() { - let handler = EnvShortcodeHandler::new(Default::default()); + let handler = EnvShortcodeHandler::new(Default::default(), None); let meta = ConfigValue::new_map(vec![], dummy_source_info()); // Set variable wins over fallback. @@ -2376,6 +2393,50 @@ mod tests { } } + #[test] + fn test_env_shortcode_quarto_profile_beats_real_env() { + // bd-fu16z22k: `{{< env QUARTO_PROFILE >}}` must resolve the + // NORMALIZED active list, even when the real environment + // still holds the raw pre-`--profile` value. + // SAFETY: test-local; nextest runs each test in its own process. + unsafe { std::env::set_var("QUARTO_PROFILE", "stale-raw-value") }; + let handler = + EnvShortcodeHandler::new(Default::default(), Some("advanced,production".to_string())); + let meta = ConfigValue::new_map(vec![], dummy_source_info()); + let shortcode = make_shortcode("env", vec!["QUARTO_PROFILE"]); + let ctx = ShortcodeContext { + metadata: &meta, + source_info: &shortcode.source_info, + }; + match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { + ShortcodeResult::Inlines(inlines) => { + let Inline::Str(s) = &inlines[0] else { + panic!("expected Str"); + }; + assert_eq!(s.text, "advanced,production"); + } + _ => panic!("Expected Inlines"), + } + + // With no active profiles the special case is inert: the + // real environment value shows through unchanged. + let handler = EnvShortcodeHandler::new(Default::default(), None); + let shortcode = make_shortcode("env", vec!["QUARTO_PROFILE"]); + let ctx = ShortcodeContext { + metadata: &meta, + source_info: &shortcode.source_info, + }; + match handler.resolve(&shortcode, &ctx, ResolutionContext::Inline) { + ShortcodeResult::Inlines(inlines) => { + let Inline::Str(s) = &inlines[0] else { + panic!("expected Str"); + }; + assert_eq!(s.text, "stale-raw-value"); + } + _ => panic!("Expected Inlines"), + } + } + #[test] fn test_env_shortcode_handler_project_env() { let mut project_env = hashlink::LinkedHashMap::new(); @@ -2387,7 +2448,7 @@ mod tests { "QUARTO_TEST_ENVFILE_SHADOWED".to_string(), "from-file".to_string(), ); - let handler = EnvShortcodeHandler::new(project_env); + let handler = EnvShortcodeHandler::new(project_env, None); let meta = ConfigValue::new_map(vec![], dummy_source_info()); // Defined only in the project env map: map value used (even @@ -3337,6 +3398,7 @@ mod tests { "html".to_string(), None, hashlink::LinkedHashMap::new(), + None, ); let entry = |key: &str, value: ConfigValue| ConfigMapEntry { @@ -3414,6 +3476,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let mut ast = Pandoc { @@ -3464,6 +3527,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let mut ast = Pandoc { @@ -3512,6 +3576,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let meta = ConfigValue::new_map( @@ -3560,6 +3625,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let mut ast = Pandoc { @@ -3614,6 +3680,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); // Shortcode alone in Para → block context @@ -3660,6 +3727,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let mut ast = Pandoc { @@ -3716,6 +3784,7 @@ mod tests { "html".to_string(), None, Default::default(), + None, ); let tok = token_si(); diff --git a/crates/quarto-core/tests/integration/main.rs b/crates/quarto-core/tests/integration/main.rs index 1811ce66c..dba3cc5e6 100644 --- a/crates/quarto-core/tests/integration/main.rs +++ b/crates/quarto-core/tests/integration/main.rs @@ -46,6 +46,7 @@ pub mod page_navigation_pipeline; pub mod preview_render_css_parity; pub mod printable_render; pub mod project_pipeline; +pub mod project_profile_overlays; pub mod project_resources; pub mod project_type_parsing; pub mod render_page_in_project; diff --git a/crates/quarto-core/tests/integration/project_profile_overlays.rs b/crates/quarto-core/tests/integration/project_profile_overlays.rs new file mode 100644 index 000000000..2a3db52fd --- /dev/null +++ b/crates/quarto-core/tests/integration/project_profile_overlays.rs @@ -0,0 +1,611 @@ +/* + * tests/integration/project_profile_overlays.rs + * Copyright (c) 2026 Posit, PBC + * + * Project-profile config overlays (bd-fu16z22k, Phase 1). + */ + +//! `_quarto-.yml` overlay discovery and merging contract. +//! +//! Covers: overlay merge semantics (scalar override, map deep-merge, +//! array concat, `!prefer`), first-listed-wins ordering, +//! `_quarto.yml.local` as the highest-priority layer and as a +//! `profile.default` source, activation from `profile.default` / +//! `profile.group`, the Q-5-19 unknown-profile warning, Q-5-22 inert +//! `profile:` keys in overlays, hard Q-5-20/21 errors aborting +//! discovery, and FileId/span integrity for diagnostics anchored in +//! overlay files. +//! +//! ⚠️ "Profile" here means *project profiles* (`--profile`, +//! `QUARTO_PROFILE`), not `DocumentProfile` (the pass-1 summary). + +use std::path::Path; +use std::sync::Arc; + +use tempfile::TempDir; + +use quarto_core::error::QuartoError; +use quarto_core::project::ProjectContext; +use quarto_error_reporting::{DiagnosticKind, DiagnosticMessage}; +use quarto_system_runtime::{NativeRuntime, SystemRuntime}; + +/// Write a project fixture and discover it with an explicit profile +/// selection (`None` = no `--profile`; the process environment is not +/// consulted because tests must not depend on the caller's env). +fn discover_with( + files: &[(&str, &str)], + selection: Option<&[&str]>, +) -> (quarto_core::error::Result, TempDir) { + let tmp = TempDir::new().unwrap(); + for (name, content) in files { + std::fs::write(tmp.path().join(name), content).unwrap(); + } + std::fs::write(tmp.path().join("index.qmd"), "# Hello\n").unwrap(); + let runtime: Arc = Arc::new(NativeRuntime::new()); + let owned: Option> = selection.map(|s| s.iter().map(|p| p.to_string()).collect()); + let result = + ProjectContext::discover_with_profile(tmp.path(), runtime.as_ref(), owned.as_deref()); + (result, tmp) +} + +fn ok(result: quarto_core::error::Result) -> ProjectContext { + result.expect("discovery must succeed") +} + +fn parse_error(result: quarto_core::error::Result) -> quarto_core::ParseError { + match result { + Err(QuartoError::Parse(pe)) => pe, + Err(other) => panic!("expected QuartoError::Parse, got: {other:?}"), + Ok(_) => panic!("expected discovery to fail"), + } +} + +/// The merged metadata value at `path`, as a string. +fn meta_str(project: &ProjectContext, path: &[&str]) -> Option { + project + .config + .metadata + .as_ref() + .and_then(|m| m.get_path(path)) + .and_then(|v| v.as_plain_text()) +} + +fn diags_with_code<'a>(diags: &'a [DiagnosticMessage], code: &str) -> Vec<&'a DiagnosticMessage> { + diags + .iter() + .filter(|d| d.code.as_deref() == Some(code)) + .collect() +} + +fn active_names(project: &ProjectContext) -> Vec<&str> { + project + .config + .active_config_profiles + .iter() + .map(|p| p.name.as_str()) + .collect() +} + +// ── merge semantics ───────────────────────────────────────────────── + +#[test] +fn overlay_scalar_overrides_base() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "execute:\n freeze: true\n"), + ("_quarto-prod.yml", "execute:\n freeze: false\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + let freeze = project + .config + .metadata + .as_ref() + .and_then(|m| m.get_path(&["execute", "freeze"])) + .and_then(|v| v.as_bool()); + assert_eq!(freeze, Some(false), "overlay scalar must win over base"); +} + +#[test] +fn overlay_map_deep_merges_with_base() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "format:\n html:\n toc: true\n"), + ( + "_quarto-prod.yml", + "format:\n html:\n code-fold: true\n", + ), + ], + Some(&["prod"]), + ); + let project = ok(result); + let get_bool = |path: &[&str]| { + project + .config + .metadata + .as_ref() + .and_then(|m| m.get_path(path)) + .and_then(|v| v.as_bool()) + }; + assert_eq!( + get_bool(&["format", "html", "toc"]), + Some(true), + "base key survives" + ); + assert_eq!( + get_bool(&["format", "html", "code-fold"]), + Some(true), + "overlay key added" + ); +} + +#[test] +fn overlay_array_concats_by_default() { + // Deliberate divergence from Q1 (union-with-dedup): Q2's Concat + // appends. Documented in the plan's divergence table. + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "extras: [alpha]\n"), + ("_quarto-prod.yml", "extras: [beta]\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + let extras: Vec = project + .config + .metadata + .as_ref() + .and_then(|m| m.get("extras")) + .and_then(|v| v.as_array().map(|a| a.to_vec())) + .map(|a| a.iter().filter_map(|v| v.as_plain_text()).collect()) + .unwrap_or_default(); + assert_eq!(extras, vec!["alpha", "beta"]); +} + +#[test] +fn overlay_prefer_tag_replaces_array() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "extras: [alpha]\n"), + ("_quarto-prod.yml", "extras: !prefer [beta]\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + let extras: Vec = project + .config + .metadata + .as_ref() + .and_then(|m| m.get("extras")) + .and_then(|v| v.as_array().map(|a| a.to_vec())) + .map(|a| a.iter().filter_map(|v| v.as_plain_text()).collect()) + .unwrap_or_default(); + assert_eq!(extras, vec!["beta"], "!prefer must replace, not append"); +} + +#[test] +fn first_listed_profile_wins_conflicts() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-a.yml", "winner: profile-a\n"), + ("_quarto-b.yml", "winner: profile-b\n"), + ], + Some(&["a", "b"]), + ); + let project = ok(result); + assert_eq!( + meta_str(&project, &["winner"]).as_deref(), + Some("profile-a"), + "the FIRST-listed profile must win conflicts (Q1 parity)" + ); + assert_eq!(active_names(&project), vec!["a", "b"]); +} + +#[test] +fn local_config_overrides_profiles() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-prod.yml", "winner: prod\n"), + ("_quarto.yml.local", "winner: local\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + assert_eq!( + meta_str(&project, &["winner"]).as_deref(), + Some("local"), + "_quarto.yml.local is the highest-priority layer" + ); +} + +#[test] +fn local_config_applies_without_profiles_too() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto.yml.local", "winner: local\n"), + ], + None, + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("local")); +} + +#[test] +fn overlay_yml_preferred_over_yaml() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-p.yml", "winner: from-yml\n"), + ("_quarto-p.yaml", "winner: from-yaml\n"), + ], + Some(&["p"]), + ); + let project = ok(result); + assert_eq!( + meta_str(&project, &["winner"]).as_deref(), + Some("from-yml"), + ".yml must be preferred when both extensions exist (Q1 parity)" + ); +} + +#[test] +fn overlay_for_inactive_profile_is_ignored() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-other.yml", "winner: other\n"), + ], + None, + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("base")); + assert!(project.config.config_diagnostics.is_empty()); + assert!(active_names(&project).is_empty()); +} + +// ── activation from config ────────────────────────────────────────── + +#[test] +fn profile_default_in_base_activates_overlay() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\nprofile:\n default: prod\n"), + ("_quarto-prod.yml", "winner: prod\n"), + ], + None, + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("prod")); + assert_eq!(active_names(&project), vec!["prod"]); + assert!( + project + .config + .metadata + .as_ref() + .is_some_and(|m| m.get("profile").is_none()), + "profile: must be stripped from the merged metadata" + ); +} + +#[test] +fn group_first_member_activates_when_none_selected() { + let (result, _tmp) = discover_with( + &[ + ( + "_quarto.yml", + "winner: base\nprofile:\n group: [basic, advanced]\n", + ), + ("_quarto-basic.yml", "winner: basic\n"), + ("_quarto-advanced.yml", "winner: advanced\n"), + ], + None, + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("basic")); + assert_eq!(active_names(&project), vec!["basic"]); +} + +#[test] +fn group_satisfied_by_explicit_selection() { + let (result, _tmp) = discover_with( + &[ + ( + "_quarto.yml", + "winner: base\nprofile:\n group: [basic, advanced]\n", + ), + ("_quarto-basic.yml", "winner: basic\n"), + ("_quarto-advanced.yml", "winner: advanced\n"), + ], + Some(&["advanced"]), + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("advanced")); + assert_eq!(active_names(&project), vec!["advanced"]); +} + +#[test] +fn local_profile_default_beats_base_default() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\nprofile:\n default: a\n"), + ("_quarto.yml.local", "profile:\n default: b\n"), + ("_quarto-a.yml", "winner: from-a\n"), + ("_quarto-b.yml", "winner: from-b\n"), + ], + None, + ); + let project = ok(result); + assert_eq!( + meta_str(&project, &["winner"]).as_deref(), + Some("from-b"), + "_quarto.yml.local's profile.default must beat _quarto.yml's" + ); + assert_eq!(active_names(&project), vec!["b"]); +} + +#[test] +fn explicit_selection_beats_config_defaults() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\nprofile:\n default: a\n"), + ("_quarto-a.yml", "winner: from-a\n"), + ("_quarto-b.yml", "winner: from-b\n"), + ], + Some(&["b"]), + ); + let project = ok(result); + assert_eq!(meta_str(&project, &["winner"]).as_deref(), Some("from-b")); + assert_eq!(active_names(&project), vec!["b"]); +} + +// ── profile-aware project fields ──────────────────────────────────── + +#[test] +fn project_fields_are_profile_aware() { + let (result, tmp) = discover_with( + &[ + ("_quarto.yml", "project:\n render:\n - index.qmd\n"), + ( + "_quarto-prod.yml", + "project:\n output-dir: _prod\n pre-render: gen.py\n", + ), + ], + Some(&["prod"]), + ); + let project = ok(result); + assert_eq!( + project.config.output_dir.as_deref(), + Some(Path::new("_prod")), + "project.output-dir from the overlay must take effect" + ); + assert_eq!(project.config.pre_render_scripts.len(), 1); + assert_eq!(project.config.pre_render_scripts[0].command, "gen.py"); + assert_eq!( + project.output_dir, + tmp.path().canonicalize().unwrap().join("_prod"), + "the resolved ProjectContext.output_dir must honor the overlay" + ); +} + +// ── diagnostics ───────────────────────────────────────────────────── + +#[test] +fn profile_key_in_overlay_warns_q_5_22_and_is_stripped() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ( + "_quarto-prod.yml", + "winner: prod\nprofile:\n default: other\n", + ), + ], + Some(&["prod"]), + ); + let project = ok(result); + let warnings = diags_with_code(&project.config.config_diagnostics, "Q-5-22"); + assert_eq!( + warnings.len(), + 1, + "got: {:?}", + project.config.config_diagnostics + ); + assert_eq!(warnings[0].kind, DiagnosticKind::Warning); + assert!( + project + .config + .metadata + .as_ref() + .is_some_and(|m| m.get("profile").is_none()), + "the overlay's profile: key must not leak into merged metadata" + ); + // The inert `default: other` must not have activated anything. + assert_eq!(active_names(&project), vec!["prod"]); +} + +#[test] +fn q_5_22_warning_span_binds_to_the_overlay_file() { + let (result, tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-prod.yml", "profile:\n default: other\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + let warnings = diags_with_code(&project.config.config_diagnostics, "Q-5-22"); + assert_eq!(warnings.len(), 1); + let location = warnings[0] + .location + .as_ref() + .expect("Q-5-22 must carry the overlay span"); + + // Span integrity: the location's FileId must re-derive from the + // overlay file's path — bind_config_source must pick the overlay, + // not `_quarto.yml` (bd-m6wmztln discipline). + let mut ctx = quarto_source_map::SourceContext::new(); + let candidates: Vec<&Path> = project + .config + .config_path + .iter() + .map(PathBuf::as_path) + .chain( + project + .config + .profile_config_paths + .iter() + .map(PathBuf::as_path), + ) + .collect(); + let matched = quarto_core::config_sources::bind_config_source(&mut ctx, location, candidates); + let overlay_path = tmp.path().canonicalize().unwrap().join("_quarto-prod.yml"); + assert_eq!( + matched, + Some(overlay_path.as_path()), + "the diagnostic's FileId must bind to _quarto-prod.yml" + ); +} + +#[test] +fn unknown_profile_warns_q_5_19() { + let (result, _tmp) = discover_with(&[("_quarto.yml", "winner: base\n")], Some(&["produciton"])); + let project = ok(result); + let warnings = diags_with_code(&project.config.config_diagnostics, "Q-5-19"); + assert_eq!( + warnings.len(), + 1, + "got: {:?}", + project.config.config_diagnostics + ); + assert_eq!(warnings[0].kind, DiagnosticKind::Warning); + let text = warnings[0].to_text(None); + assert!(text.contains("produciton"), "must name the profile: {text}"); +} + +#[test] +fn declared_profile_without_files_does_not_warn() { + // A profile that exists only for conditional content is declared + // via profile.group (or default) to silence Q-5-19. + let (result, _tmp) = discover_with( + &[( + "_quarto.yml", + "winner: base\nprofile:\n group: [basic, advanced]\n", + )], + Some(&["advanced"]), + ); + let project = ok(result); + assert!( + diags_with_code(&project.config.config_diagnostics, "Q-5-19").is_empty(), + "declared profiles must not warn: {:?}", + project.config.config_diagnostics + ); +} + +#[test] +fn environment_file_silences_q_5_19() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_environment-prod", "OMP_NUM_THREADS=16\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + assert!( + diags_with_code(&project.config.config_diagnostics, "Q-5-19").is_empty(), + "an _environment- file makes the profile known: {:?}", + project.config.config_diagnostics + ); +} + +#[test] +fn matched_overlay_does_not_warn_q_5_19() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-prod.yml", "winner: prod\n"), + ], + Some(&["prod"]), + ); + let project = ok(result); + assert!(diags_with_code(&project.config.config_diagnostics, "Q-5-19").is_empty()); +} + +// ── hard errors ───────────────────────────────────────────────────── + +#[test] +fn mixed_shape_group_aborts_discovery_with_span() { + let (result, _tmp) = discover_with( + &[("_quarto.yml", "profile:\n group:\n - a\n - [b, c]\n")], + None, + ); + let pe = parse_error(result); + assert!( + pe.diagnostics + .iter() + .any(|d| d.code.as_deref() == Some("Q-5-20")), + "got: {:?}", + pe.diagnostics + ); + let text = pe.render(); + assert!( + text.contains("_quarto.yml"), + "the error must render a snippet from _quarto.yml: {text}" + ); +} + +#[test] +fn invalid_profile_name_in_selection_aborts_discovery() { + let (result, _tmp) = discover_with(&[("_quarto.yml", "winner: base\n")], Some(&["bad/name"])); + let pe = parse_error(result); + assert!( + pe.diagnostics + .iter() + .any(|d| d.code.as_deref() == Some("Q-5-21")), + "got: {:?}", + pe.diagnostics + ); +} + +#[test] +fn overlay_yaml_parse_error_aborts_discovery() { + let (result, _tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-prod.yml", "winner: [unclosed\n"), + ], + Some(&["prod"]), + ); + assert!( + result.is_err(), + "a malformed overlay must abort, matching Q1's loud failure" + ); +} + +// ── bookkeeping ───────────────────────────────────────────────────── + +use std::path::PathBuf; + +#[test] +fn profile_config_paths_record_files_actually_read() { + let (result, tmp) = discover_with( + &[ + ("_quarto.yml", "winner: base\n"), + ("_quarto-a.yml", "winner: a\n"), + ("_quarto.yml.local", "winner: local\n"), + ], + // `b` has no overlay file: it must not appear in the paths. + Some(&["a", "b"]), + ); + let project = ok(result); + let root = tmp.path().canonicalize().unwrap(); + assert_eq!( + project.config.profile_config_paths, + vec![root.join("_quarto-a.yml"), root.join("_quarto.yml.local")], + "paths of the overlay + local files actually read, in merge order" + ); + assert_eq!(active_names(&project), vec!["a", "b"]); +} diff --git a/crates/quarto-error-catalog/error_catalog.json b/crates/quarto-error-catalog/error_catalog.json index 7a711347e..6335a882b 100644 --- a/crates/quarto-error-catalog/error_catalog.json +++ b/crates/quarto-error-catalog/error_catalog.json @@ -454,6 +454,13 @@ "docs_url": "https://quarto.org/docs/errors/markdown/Q-2-41", "since_version": "99.9.9" }, + "Q-2-42": { + "subsystem": "markdown", + "title": "Invalid Conditional Content Attribute", + "message_template": "A `.content-visible` / `.content-hidden` element carries a condition attribute Quarto does not recognize. The supported conditions are `when-format`, `unless-format`, `when-profile`, `unless-profile`, `when-meta`, and `unless-meta`; anything else starting with `when-` or `unless-` is probably a typo and is ignored (Quarto 1 ignores it silently). Also reported when one element carries both `.content-visible` and `.content-hidden` (the element is hidden).", + "docs_url": "https://quarto.org/docs/errors/markdown/Q-2-42", + "since_version": "99.9.9" + }, "Q-3-1": { "subsystem": "writer", "title": "IO Error During Write", @@ -1189,6 +1196,34 @@ "docs_url": "https://quarto.org/docs/errors/project/Q-5-18", "since_version": "99.9.9" }, + "Q-5-19": { + "subsystem": "project", + "title": "Unknown Project Profile", + "message_template": "An active project profile matches nothing in the project: there is no `_quarto-.yml` overlay, no `_environment-` file, and the name is not declared under `profile:` in `_quarto.yml`. This is usually a typo in `--profile`, `QUARTO_PROFILE`, or `profile.default`. If the profile exists only for conditional content (`when-profile`), declare it under `profile.group` or `profile.default` to silence this warning.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-19", + "since_version": "99.9.9" + }, + "Q-5-20": { + "subsystem": "project", + "title": "Invalid Profile Configuration", + "message_template": "The `profile:` key must be a mapping with the keys `default` (a profile name or list of profile names) and `group` (a list of profile names, or a list of such lists). Quarto 1 silently ignores malformed `profile:` configuration; Quarto 2 reports it so a mistyped configuration cannot silently select the wrong profiles.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-20", + "since_version": "99.9.9" + }, + "Q-5-21": { + "subsystem": "project", + "title": "Invalid Profile Name", + "message_template": "Project profile names must start with an ASCII letter or digit and contain only ASCII letters, digits, `.`, `_`, and `-` (they name files such as `_quarto-.yml`). An explicitly given but empty selection (for example `--profile \"\"` or `QUARTO_PROFILE=\" , \"`) is also an error: use no flag at all to select no profiles.", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-21", + "since_version": "99.9.9" + }, + "Q-5-22": { + "subsystem": "project", + "title": "Profile Key Has No Effect Here", + "message_template": "A `profile:` key appears in a file where it has no effect and is ignored: profile overlay files (`_quarto-.yml`) never contribute profile configuration, and `_quarto.yml.local` contributes only `profile.default` (profile groups are read from `_quarto.yml` only).", + "docs_url": "https://quarto.org/docs/errors/project/Q-5-22", + "since_version": "99.9.9" + }, "Q-5-13": { "subsystem": "project", "title": "`project.render` Pattern Matched No Files", diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index 1748ce408..d3593d540 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -338,6 +338,10 @@ fn run_boot_pre_render_scripts(project_root: &std::path::Path) { output_dir: &project.output_dir, config_path: project.config.config_path.as_deref(), extension_manifest_paths: &project.config.extension_manifest_paths, + profile_config_paths: &project.config.profile_config_paths, + quarto_profile: quarto_core::project::project_profile::quarto_profile_env_value( + &project.config.active_config_profiles, + ), render_all: true, quiet: false, file_count: input_files.len(), diff --git a/crates/quarto-util/src/verbose.rs b/crates/quarto-util/src/verbose.rs index 0247aa5b3..0f543c187 100644 --- a/crates/quarto-util/src/verbose.rs +++ b/crates/quarto-util/src/verbose.rs @@ -26,12 +26,17 @@ /// directive set is a closed, well-known list — callers should not /// post-process or concatenate beyond passing them straight into /// `EnvFilter::new`. +/// The `quarto` directive prefix-matches every `quarto*` crate +/// (`quarto_core`, `quarto_preview`, …); the separate `q2` directive +/// covers the `q2` *binary* crate itself, whose tracing targets start +/// with `q2::` and never matched `quarto` (bd-fu16z22k found this +/// while adding the `-v` active-profile echo). pub fn verbose_to_filter(count: u8) -> &'static str { match count { - 0 => "quarto=warn", - 1 => "quarto=info", - 2 => "quarto=debug,samod=info", - _ => "quarto=trace,samod=debug,tower_http=debug", + 0 => "quarto=warn,q2=warn", + 1 => "quarto=info,q2=info", + 2 => "quarto=debug,q2=debug,samod=info", + _ => "quarto=trace,q2=trace,samod=debug,tower_http=debug", } } @@ -41,24 +46,24 @@ mod tests { #[test] fn level_zero_is_warn_floor() { - assert_eq!(verbose_to_filter(0), "quarto=warn"); + assert_eq!(verbose_to_filter(0), "quarto=warn,q2=warn"); } #[test] fn single_v_is_info() { - assert_eq!(verbose_to_filter(1), "quarto=info"); + assert_eq!(verbose_to_filter(1), "quarto=info,q2=info"); } #[test] fn double_v_adds_samod_info() { - assert_eq!(verbose_to_filter(2), "quarto=debug,samod=info"); + assert_eq!(verbose_to_filter(2), "quarto=debug,q2=debug,samod=info"); } #[test] fn triple_v_adds_tower_http_and_samod_debug() { assert_eq!( verbose_to_filter(3), - "quarto=trace,samod=debug,tower_http=debug" + "quarto=trace,q2=trace,samod=debug,tower_http=debug" ); } diff --git a/crates/quarto/src/commands/get_config.rs b/crates/quarto/src/commands/get_config.rs index d8228d7d8..d982cef1e 100644 --- a/crates/quarto/src/commands/get_config.rs +++ b/crates/quarto/src/commands/get_config.rs @@ -53,6 +53,8 @@ pub struct GetConfigArgs { pub strict: bool, /// Emit single-line JSON instead of pretty-printed. pub compact: bool, + /// `--profile` values; non-empty replaces `QUARTO_PROFILE` (bd-fu16z22k). + pub profile: Vec, } /// Compute the JSON value for the requested path. @@ -69,8 +71,12 @@ pub fn get_config_value(args: &GetConfigArgs) -> Result> { .canonicalize() .with_context(|| format!("Cannot read document: {}", args.file.display()))?; - let project = ProjectContext::discover(&input, runtime.as_ref()) - .context("Failed to discover project context")?; + let project = ProjectContext::discover_with_profile( + &input, + runtime.as_ref(), + quarto_core::project::project_profile::cli_selection(&args.profile), + ) + .context("Failed to discover project context")?; let format = Format::from_format_string(&args.to) .map_err(|e| anyhow::anyhow!("Invalid --to format '{}': {}", args.to, e))?; diff --git a/crates/quarto/src/commands/publish.rs b/crates/quarto/src/commands/publish.rs index e18f15d4e..900e3e836 100644 --- a/crates/quarto/src/commands/publish.rs +++ b/crates/quarto/src/commands/publish.rs @@ -31,6 +31,9 @@ pub struct PublishArgs { pub no_wait: bool, pub dry_run: bool, pub json: bool, + /// `--profile` values; `Some`-like semantics via non-empty vec — + /// replaces `QUARTO_PROFILE` when non-empty (bd-fu16z22k). + pub profile: Vec, } /// Execute the `quarto publish` command. @@ -70,8 +73,14 @@ pub fn execute(args: PublishArgs) -> Result<()> { None => cwd.clone(), }; - let project = ProjectContext::discover(&path, runtime.as_ref()) - .context("failed to discover project context for publish")?; + let profile_selection: Option> = + quarto_core::project::project_profile::cli_selection(&args.profile).map(<[String]>::to_vec); + let project = ProjectContext::discover_with_profile( + &path, + runtime.as_ref(), + profile_selection.as_deref(), + ) + .context("failed to discover project context for publish")?; let project_dir = project.dir.clone(); let title = derive_title(&project, &project_dir); @@ -97,6 +106,7 @@ pub fn execute(args: PublishArgs) -> Result<()> { let renderer = ProjectPublishRenderer { project_dir: project_dir.clone(), runtime: runtime.clone(), + profile_selection, }; let registry = ProviderRegistry::with_builtins(); @@ -192,6 +202,10 @@ fn simple_slug(title: &str) -> String { struct ProjectPublishRenderer { project_dir: PathBuf, runtime: Arc, + /// `--profile` selection, carried so the render-time (re-)discovery + /// resolves the same project profiles as the top-level discovery + /// (bd-fu16z22k). + profile_selection: Option>, } #[async_trait] @@ -208,10 +222,15 @@ impl PublishRenderer for ProjectPublishRenderer { // Send + Sync, replace this with a normal `.await`. let project_dir = self.project_dir.clone(); let runtime = self.runtime.clone(); + let profile_selection = self.profile_selection.clone(); let result: Result = pollster::block_on(async move { - let mut project = ProjectContext::discover(&project_dir, runtime.as_ref()) - .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + let mut project = ProjectContext::discover_with_profile( + &project_dir, + runtime.as_ref(), + profile_selection.as_deref(), + ) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; // bd-w348iu63: run `project.pre-render` scripts before // the pipeline, then re-discover so script-created @@ -231,6 +250,10 @@ impl PublishRenderer for ProjectPublishRenderer { output_dir: &project.output_dir, config_path: project.config.config_path.as_deref(), extension_manifest_paths: &project.config.extension_manifest_paths, + profile_config_paths: &project.config.profile_config_paths, + quarto_profile: quarto_core::project::project_profile::quarto_profile_env_value( + &project.config.active_config_profiles, + ), render_all: true, quiet: false, file_count: input_files.len(), @@ -244,8 +267,12 @@ impl PublishRenderer for ProjectPublishRenderer { ) .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; - let re_project = ProjectContext::discover(&project_dir, runtime.as_ref()) - .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; + let re_project = ProjectContext::discover_with_profile( + &project_dir, + runtime.as_ref(), + profile_selection.as_deref(), + ) + .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; render_scripts::check_forbidden_mutations(&project.config, &re_project.config) .map_err(|e| PublishError::Other(anyhow::anyhow!("{e}")))?; project = re_project; @@ -288,6 +315,10 @@ impl PublishRenderer for ProjectPublishRenderer { output_dir: &project.output_dir, config_path: project.config.config_path.as_deref(), extension_manifest_paths: &project.config.extension_manifest_paths, + profile_config_paths: &project.config.profile_config_paths, + quarto_profile: quarto_core::project::project_profile::quarto_profile_env_value( + &project.config.active_config_profiles, + ), render_all: true, quiet: false, file_count: summary.outputs.len(), @@ -485,6 +516,7 @@ mod tests { let renderer = ProjectPublishRenderer { project_dir: project_dir.clone(), runtime, + profile_selection: None, }; let files = renderer diff --git a/crates/quarto/src/commands/render.rs b/crates/quarto/src/commands/render.rs index 1eb5e211a..91948ea74 100644 --- a/crates/quarto/src/commands/render.rs +++ b/crates/quarto/src/commands/render.rs @@ -101,6 +101,11 @@ pub struct RenderArgs { /// Skip the project's `pre-render` / `post-render` scripts /// (bd-w348iu63). The render itself is unaffected. pub no_render_scripts: bool, + /// `--profile` values (comma-separated or repeated). Non-empty + /// replaces `QUARTO_PROFILE` entirely; empty means "not given" + /// (bd-fu16z22k). See + /// [`quarto_core::project::project_profile::cli_selection`]. + pub profile: Vec, } /// What to render after argument classification. @@ -248,9 +253,10 @@ pub fn classify_inputs( inputs: &[String], cwd: &Path, runtime: &dyn SystemRuntime, + profile_selection: Option<&[String]>, ) -> std::result::Result { if inputs.is_empty() { - return classify_no_inputs(cwd, runtime); + return classify_no_inputs(cwd, runtime, profile_selection); } // Step 1: canonicalize each input and verify it exists. @@ -277,7 +283,8 @@ pub fn classify_inputs( let mut shared_project: Option = None; let mut any_outside_project = false; for r in &resolved { - let ctx = ProjectContext::discover(r, runtime).map_err(discover_error)?; + let ctx = ProjectContext::discover_with_profile(r, runtime, profile_selection) + .map_err(discover_error)?; // `is_single_file` only fires for *file* inputs with no // surrounding `_quarto.yml`. A *directory* input never trips // it, even when no config exists — so we re-check the @@ -335,7 +342,8 @@ pub fn classify_inputs( // Re-discover from the project root to get the full // render-list-filtered file list. (Per-input `discover` only fills // `files` with that one input.) - let project = ProjectContext::discover(&project_dir, runtime).map_err(discover_error)?; + let project = ProjectContext::discover_with_profile(&project_dir, runtime, profile_selection) + .map_err(discover_error)?; let project_files: Vec = project.files.iter().map(|f| f.input.clone()).collect(); // Step 3: expand each input into the set of project files it @@ -403,6 +411,7 @@ pub fn classify_inputs( fn classify_no_inputs( cwd: &Path, runtime: &dyn SystemRuntime, + profile_selection: Option<&[String]>, ) -> std::result::Result { let cwd_canon = runtime .canonicalize(cwd) @@ -420,7 +429,8 @@ fn classify_no_inputs( return Err(DispatchError::NoInputAndNoProject(cwd_canon)); }; - let project = ProjectContext::discover(&project_root, runtime).map_err(discover_error)?; + let project = ProjectContext::discover_with_profile(&project_root, runtime, profile_selection) + .map_err(discover_error)?; Ok(RenderTarget::FullProject { project_dir: project.dir, }) @@ -678,7 +688,12 @@ pub fn execute(args: RenderArgs) -> Result<()> { // surface the structured DispatchError as a JsonDiagnostic on // stderr before exiting so machine consumers can discriminate // by code (Q-7-2..8). - let target = match classify_inputs(&args.inputs, &cwd, &runtime) { + let target = match classify_inputs( + &args.inputs, + &cwd, + &runtime, + quarto_core::project::project_profile::cli_selection(&args.profile), + ) { Ok(t) => t, Err(e) => { if args.json_errors { @@ -736,6 +751,22 @@ pub fn execute(args: RenderArgs) -> Result<()> { } } +/// `-v` echo of the resolved project-profile activation set with +/// per-profile provenance (bd-fu16z22k). Deliberately absent from +/// normal output (Q1 parity: profiles are never announced); shows at +/// `-v` because `verbose_to_filter(1)` enables `quarto=info`. +fn echo_active_profiles(project: &ProjectContext) { + let active = &project.config.active_config_profiles; + if active.is_empty() { + return; + } + let described: Vec = active + .iter() + .map(|p| format!("{} (from {})", p.name, p.source.describe())) + .collect(); + tracing::info!("active project profiles: {}", described.join(", ")); +} + fn execute_single_doc( input: PathBuf, args: &RenderArgs, @@ -743,8 +774,13 @@ fn execute_single_doc( format: Format, ) -> Result<()> { let runtime_arc: Arc = Arc::new(NativeRuntime::new()); - let mut project = ProjectContext::discover(&input, runtime_arc.as_ref()) - .context("Failed to discover project context")?; + let mut project = ProjectContext::discover_with_profile( + &input, + runtime_arc.as_ref(), + quarto_core::project::project_profile::cli_selection(&args.profile), + ) + .context("Failed to discover project context")?; + echo_active_profiles(&project); // Captured before the pipeline mutably borrows `project`; used to // restore config-anchored source snippets at print time. Manifests // included: merged values can anchor in an extension's @@ -753,6 +789,7 @@ fn execute_single_doc( let config_sources: Vec = config_path .iter() .cloned() + .chain(project.config.profile_config_paths.iter().cloned()) .chain(project.config.extension_manifest_paths.iter().cloned()) .collect(); @@ -832,8 +869,13 @@ fn execute_project( run_clean_cache(runtime_arc.as_ref(), &project_dir).map_err(|e| anyhow::anyhow!("{e}"))?; } - let mut project = ProjectContext::discover(&project_dir, runtime_arc.as_ref()) - .context("Failed to discover project context")?; + let mut project = ProjectContext::discover_with_profile( + &project_dir, + runtime_arc.as_ref(), + quarto_core::project::project_profile::cli_selection(&args.profile), + ) + .context("Failed to discover project context")?; + echo_active_profiles(&project); // Captured before the pipeline mutably borrows `project`; used to // restore config-anchored source snippets at print time. Manifests // included: merged values can anchor in an extension's @@ -842,6 +884,7 @@ fn execute_project( let config_sources: Vec = config_path .iter() .cloned() + .chain(project.config.profile_config_paths.iter().cloned()) .chain(project.config.extension_manifest_paths.iter().cloned()) .collect(); @@ -881,6 +924,10 @@ fn execute_project( output_dir: &project.output_dir, config_path: project.config.config_path.as_deref(), extension_manifest_paths: &project.config.extension_manifest_paths, + profile_config_paths: &project.config.profile_config_paths, + quarto_profile: quarto_core::project::project_profile::quarto_profile_env_value( + &project.config.active_config_profiles, + ), render_all, quiet: args.quiet, file_count: input_files.len(), @@ -895,8 +942,12 @@ fn execute_project( exit_with_parse_error(parse_error, args); } - let re_project = ProjectContext::discover(&project_dir, runtime_arc.as_ref()) - .context("Failed to re-discover project context after pre-render scripts")?; + let re_project = ProjectContext::discover_with_profile( + &project_dir, + runtime_arc.as_ref(), + quarto_core::project::project_profile::cli_selection(&args.profile), + ) + .context("Failed to re-discover project context after pre-render scripts")?; if let Err(parse_error) = render_scripts::check_forbidden_mutations(&project.config, &re_project.config) { @@ -994,6 +1045,10 @@ fn execute_project( output_dir: &project.output_dir, config_path: project.config.config_path.as_deref(), extension_manifest_paths: &project.config.extension_manifest_paths, + profile_config_paths: &project.config.profile_config_paths, + quarto_profile: quarto_core::project::project_profile::quarto_profile_env_value( + &project.config.active_config_profiles, + ), render_all, quiet: args.quiet, file_count: total_files, @@ -1738,7 +1793,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_project(&temp, &["index.qmd", "about.qmd"], None); let runtime = NativeRuntime::new(); - let target = classify_inputs(&[], &project, &runtime).unwrap(); + let target = classify_inputs(&[], &project, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::FullProject { @@ -1758,7 +1813,7 @@ mod tests { write_file(&dir.join("_quarto.yml"), "project:\n type: posit-docs\n"); write_file(&dir.join("index.qmd"), "---\ntitle: x\n---\n"); let runtime = NativeRuntime::new(); - let err = classify_inputs(&[], &dir, &runtime).unwrap_err(); + let err = classify_inputs(&[], &dir, &runtime, None).unwrap_err(); let DispatchError::DiscoverParse(pe) = err else { panic!("expected DiscoverParse, got {err:?}"); }; @@ -1771,7 +1826,7 @@ mod tests { let temp = TempDir::new().unwrap(); let dir = make_loose_dir(&temp, &["foo.qmd"]); let runtime = NativeRuntime::new(); - let err = classify_inputs(&[], &dir, &runtime).unwrap_err(); + let err = classify_inputs(&[], &dir, &runtime, None).unwrap_err(); assert!( matches!(err, DispatchError::NoInputAndNoProject(_)), "expected NoInputAndNoProject, got {err:?}" @@ -1798,7 +1853,7 @@ mod tests { write_file(&dir.join("sub/decoy.qmd"), "---\ntitle: decoy\n---\n"); let runtime = RecordingRuntime::new(); - let err = classify_inputs(&[], &dir, &runtime).unwrap_err(); + let err = classify_inputs(&[], &dir, &runtime, None).unwrap_err(); assert!( matches!(err, DispatchError::NoInputAndNoProject(_)), "expected NoInputAndNoProject, got {err:?}" @@ -1822,7 +1877,7 @@ mod tests { let project = make_project(&temp, &["index.qmd", "sub/a.qmd", "sub/sub2/b.qmd"], None); let nested = project.join("sub").join("sub2"); let runtime = NativeRuntime::new(); - let target = classify_inputs(&[], &nested, &runtime).unwrap(); + let target = classify_inputs(&[], &nested, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::FullProject { @@ -1836,7 +1891,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_project(&temp, &["index.qmd", "about.qmd"], None); let runtime = NativeRuntime::new(); - let target = classify_inputs(&["about.qmd".into()], &project, &runtime).unwrap(); + let target = classify_inputs(&["about.qmd".into()], &project, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::Subset { @@ -1851,7 +1906,7 @@ mod tests { let temp = TempDir::new().unwrap(); let dir = make_loose_dir(&temp, &["foo.qmd"]); let runtime = NativeRuntime::new(); - let target = classify_inputs(&["foo.qmd".into()], &dir, &runtime).unwrap(); + let target = classify_inputs(&["foo.qmd".into()], &dir, &runtime, None).unwrap(); assert_eq!(target, RenderTarget::SingleDoc(dir.join("foo.qmd"))); } @@ -1861,8 +1916,13 @@ mod tests { let project = make_project(&temp, &["index.qmd", "about.qmd"], None); let cwd = canonical(temp.path()); // any cwd let runtime = NativeRuntime::new(); - let target = - classify_inputs(&[project.to_string_lossy().into_owned()], &cwd, &runtime).unwrap(); + let target = classify_inputs( + &[project.to_string_lossy().into_owned()], + &cwd, + &runtime, + None, + ) + .unwrap(); assert_eq!( target, RenderTarget::FullProject { @@ -1876,7 +1936,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_project(&temp, &["top.qmd", "sub/a.qmd", "sub/b.qmd"], None); let runtime = NativeRuntime::new(); - let target = classify_inputs(&["sub".into()], &project, &runtime).unwrap(); + let target = classify_inputs(&["sub".into()], &project, &runtime, None).unwrap(); match target { RenderTarget::Subset { project_dir, @@ -1905,7 +1965,7 @@ mod tests { let project = make_project(&temp, &["a.qmd", "b.qmd", "c.qmd"], None); let runtime = NativeRuntime::new(); let target = - classify_inputs(&["a.qmd".into(), "b.qmd".into()], &project, &runtime).unwrap(); + classify_inputs(&["a.qmd".into(), "b.qmd".into()], &project, &runtime, None).unwrap(); match target { RenderTarget::Subset { project_dir, @@ -1928,7 +1988,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_project(&temp, &["a.qmd", "b.qmd"], Some(&["a.qmd"])); let runtime = NativeRuntime::new(); - let err = classify_inputs(&["b.qmd".into()], &project, &runtime).unwrap_err(); + let err = classify_inputs(&["b.qmd".into()], &project, &runtime, None).unwrap_err(); match err { DispatchError::NotInRenderList { path, project_dir } => { assert_eq!(path, project.join("b.qmd")); @@ -1946,7 +2006,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_project(&temp, &["main.qmd", "_partial.qmd"], None); let runtime = NativeRuntime::new(); - let err = classify_inputs(&["_partial.qmd".into()], &project, &runtime).unwrap_err(); + let err = classify_inputs(&["_partial.qmd".into()], &project, &runtime, None).unwrap_err(); assert!( matches!(err, DispatchError::NotInRenderList { .. }), "expected NotInRenderList, got {err:?}" @@ -1960,7 +2020,7 @@ mod tests { // Empty subdirectory. std::fs::create_dir_all(project.join("empty")).unwrap(); let runtime = NativeRuntime::new(); - let err = classify_inputs(&["empty".into()], &project, &runtime).unwrap_err(); + let err = classify_inputs(&["empty".into()], &project, &runtime, None).unwrap_err(); match err { DispatchError::NoRenderableMatches { path } => { assert_eq!(path, project.join("empty")); @@ -1984,6 +2044,7 @@ mod tests { ], &cwd, &runtime, + None, ) .unwrap_err(); assert!( @@ -1997,7 +2058,8 @@ mod tests { let temp = TempDir::new().unwrap(); let dir = make_loose_dir(&temp, &["a.qmd", "b.qmd"]); let runtime = NativeRuntime::new(); - let err = classify_inputs(&["a.qmd".into(), "b.qmd".into()], &dir, &runtime).unwrap_err(); + let err = + classify_inputs(&["a.qmd".into(), "b.qmd".into()], &dir, &runtime, None).unwrap_err(); assert!( matches!(err, DispatchError::MultiArgNonProject), "expected MultiArgNonProject, got {err:?}" @@ -2009,7 +2071,8 @@ mod tests { let temp = TempDir::new().unwrap(); let dir = canonical(temp.path()); let runtime = NativeRuntime::new(); - let err = classify_inputs(&["does-not-exist.qmd".into()], &dir, &runtime).unwrap_err(); + let err = + classify_inputs(&["does-not-exist.qmd".into()], &dir, &runtime, None).unwrap_err(); assert!( matches!(err, DispatchError::PathNotFound(_)), "expected PathNotFound, got {err:?}" @@ -2030,7 +2093,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_default_project(&temp, &["index.qmd"]); let runtime = NativeRuntime::new(); - let target = classify_inputs(&["index.qmd".into()], &project, &runtime).unwrap(); + let target = classify_inputs(&["index.qmd".into()], &project, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::FullProject { @@ -2051,7 +2114,7 @@ mod tests { let temp = TempDir::new().unwrap(); let project = make_default_project(&temp, &["index.qmd", "about.qmd"]); let runtime = NativeRuntime::new(); - let target = classify_inputs(&[], &project, &runtime).unwrap(); + let target = classify_inputs(&[], &project, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::FullProject { @@ -2069,7 +2132,7 @@ mod tests { let project = make_project(&temp, &["a.qmd", "b.qmd"], None); let runtime = NativeRuntime::new(); let target = - classify_inputs(&["a.qmd".into(), "b.qmd".into()], &project, &runtime).unwrap(); + classify_inputs(&["a.qmd".into(), "b.qmd".into()], &project, &runtime, None).unwrap(); assert_eq!( target, RenderTarget::FullProject { diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 8d92c8afb..b2884bd43 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -396,6 +396,10 @@ enum Commands { #[arg(long = "dry-run", action = clap::ArgAction::SetTrue)] dry_run: bool, + /// Active project profile(s) (comma-separated or repeated). + #[arg(long)] + profile: Vec, + /// Emit machine-readable output (implies --no-prompt; /// final PublishOutcome on stdout, NDJSON events on stderr) #[arg(long, action = clap::ArgAction::SetTrue)] @@ -454,6 +458,10 @@ enum Commands { /// Emit compact single-line JSON instead of pretty-printed. #[arg(long)] compact: bool, + + /// Active project profile(s) (comma-separated or repeated). + #[arg(long)] + profile: Vec, }, /// Inspect pipeline execution traces under `.quarto/trace/`. @@ -741,7 +749,9 @@ fn main() -> Result<()> { tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| quarto_util::verbose_to_filter(cli.verbose).into()), ) - .with(tracing_subscriber::fmt::layer()) + // Logs go to stderr like every other q2 diagnostic — stdout + // stays reserved for command output (`get-config` JSON, etc.). + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .init(); match cli.command { @@ -759,6 +769,7 @@ fn main() -> Result<()> { fail_fast, strict, no_render_scripts, + profile, .. } => commands::render::execute(commands::render::RenderArgs { inputs, @@ -774,6 +785,7 @@ fn main() -> Result<()> { fail_fast, strict, no_render_scripts, + profile, }), Commands::Preview { path, @@ -840,10 +852,12 @@ fn main() -> Result<()> { no_wait, dry_run, json, + profile, } => commands::publish::execute(commands::publish::PublishArgs { provider, path, no_render, + profile, no_prompt, no_browser, no_wait, @@ -860,6 +874,7 @@ fn main() -> Result<()> { output, strict, compact, + profile, } => commands::get_config::execute(commands::get_config::GetConfigArgs { file, path, @@ -867,6 +882,7 @@ fn main() -> Result<()> { output, strict, compact, + profile, }), Commands::Mcp { args } => commands::mcp::run(&args), diff --git a/crates/quarto/tests/integration/conditional_content_cli.rs b/crates/quarto/tests/integration/conditional_content_cli.rs new file mode 100644 index 000000000..0e2555e3e --- /dev/null +++ b/crates/quarto/tests/integration/conditional_content_cli.rs @@ -0,0 +1,237 @@ +/* + * tests/integration/conditional_content_cli.rs + * Copyright (c) 2026 Posit, PBC + * + * Conditional content e2e (bd-fu16z22k, Phase 4). + */ + +//! `.content-visible` / `.content-hidden` with `when-`/`unless-` × +//! `format` / `profile` / `meta`, driven through the real `q2` +//! binary. Semantics ported from Quarto 1's `content-hidden.lua`: +//! condition kinds AND together, comma-separated values within one +//! condition OR (a q2 extension — Q1 only ever matches a single +//! value), `unless-*` negates, a bare `.content-hidden` always +//! hides, and surviving nodes keep their classes but lose the +//! condition attributes. + +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +const Q2_BIN: &str = env!("CARGO_BIN_EXE_q2"); + +/// Render `doc.qmd` (written from `body`, optional `front` matter) +/// inside a fresh default project; return the HTML. +fn render(body: &str, front: &str, extra: &[&str]) -> String { + let dir = TempDir::new().unwrap(); + render_in(dir.path(), body, front, extra) +} + +fn render_in(root: &Path, body: &str, front: &str, extra: &[&str]) -> String { + std::fs::write(root.join("_quarto.yml"), "project:\n type: default\n").unwrap(); + let doc = if front.is_empty() { + body.to_string() + } else { + format!("---\n{front}---\n\n{body}") + }; + std::fs::write(root.join("doc.qmd"), doc).unwrap(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(root.join("doc.qmd")) + .arg("--quiet") + .args(extra) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!( + out.status.success(), + "render failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + std::fs::read_to_string(root.join("doc.html")).expect("output exists") +} + +// ── when-profile / unless-profile ─────────────────────────────────── + +#[test] +fn when_profile_div_shows_only_under_profile() { + let body = "always-there\n\n\ + ::: {.content-visible when-profile=\"advanced\"}\nADVANCED-ONLY\n:::\n"; + let html = render(body, "", &["--profile", "advanced"]); + assert!( + html.contains("ADVANCED-ONLY"), + "visible under the profile: {html}" + ); + + let html = render(body, "", &[]); + assert!(html.contains("always-there")); + assert!( + !html.contains("ADVANCED-ONLY"), + "hidden without the profile: {html}" + ); +} + +#[test] +fn unless_profile_div_inverts() { + let body = "::: {.content-visible unless-profile=\"advanced\"}\nBASIC-ONLY\n:::\n"; + let html = render(body, "", &[]); + assert!(html.contains("BASIC-ONLY")); + let html = render(body, "", &["--profile", "advanced"]); + assert!(!html.contains("BASIC-ONLY")); +} + +#[test] +fn content_hidden_when_profile() { + let body = "::: {.content-hidden when-profile=\"advanced\"}\nSECRET\n:::\n"; + let html = render(body, "", &["--profile", "advanced"]); + assert!(!html.contains("SECRET"), "hidden under the profile: {html}"); + let html = render(body, "", &[]); + assert!(html.contains("SECRET"), "shown without it: {html}"); +} + +#[test] +fn when_profile_span_works_inline() { + let body = "Text [secret words]{.content-visible when-profile=\"advanced\"} tail.\n"; + let html = render(body, "", &[]); + assert!(!html.contains("secret words"), "span removed: {html}"); + assert!(html.contains("tail"), "surrounding text survives"); + let html = render(body, "", &["--profile", "advanced"]); + assert!(html.contains("secret words")); +} + +#[test] +fn comma_values_or_within_one_condition() { + // q2 extension: comma-separated values OR (Q1 matches literally). + let body = "::: {.content-visible when-profile=\"a,b\"}\nEITHER\n:::\n"; + let html = render(body, "", &["--profile", "b"]); + assert!(html.contains("EITHER")); + let html = render(body, "", &["--profile", "c"]); + assert!(!html.contains("EITHER")); +} + +// ── when-format / unless-format ───────────────────────────────────── + +#[test] +fn when_format_matches_via_alias_table() { + // `html` is an alias family: the concrete `html` target matches; + // `pdf` does not. + let body = "::: {.content-visible when-format=\"html\"}\nHTML-ONLY\n:::\n\ + ::: {.content-visible when-format=\"pdf\"}\nPDF-ONLY\n:::\n"; + let html = render(body, "", &[]); + assert!(html.contains("HTML-ONLY"), "{html}"); + assert!(!html.contains("PDF-ONLY"), "{html}"); +} + +#[test] +fn unless_format_inverts() { + let body = "::: {.content-hidden unless-format=\"pdf\"}\nPDF-BOUND\n:::\n"; + let html = render(body, "", &[]); + assert!(!html.contains("PDF-BOUND"), "hidden under html: {html}"); +} + +// ── when-meta / unless-meta ───────────────────────────────────────── + +#[test] +fn when_meta_dotted_path_truthiness() { + let body = "::: {.content-visible when-meta=\"features.beta\"}\nBETA\n:::\n"; + let html = render(body, "features:\n beta: true\n", &[]); + assert!(html.contains("BETA"), "truthy meta shows: {html}"); + let html = render(body, "features:\n beta: false\n", &[]); + assert!(!html.contains("BETA"), "explicit false hides: {html}"); + let html = render(body, "", &[]); + assert!(!html.contains("BETA"), "missing meta hides: {html}"); +} + +#[test] +fn when_meta_sees_profile_overlay_metadata() { + // The documented Q1 pattern: profiles set metadata, when-meta + // reads it — so profiles control content through config. + let dir = TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write(root.join("_quarto-beta.yml"), "features:\n beta: true\n").unwrap(); + let body = "::: {.content-visible when-meta=\"features.beta\"}\nBETA\n:::\n"; + let html = render_in(root, body, "", &["--profile", "beta"]); + assert!(html.contains("BETA"), "{html}"); +} + +// ── composition + structure ───────────────────────────────────────── + +#[test] +fn conditions_of_different_kinds_and_together() { + let body = "::: {.content-visible when-format=\"html\" when-profile=\"advanced\"}\nBOTH\n:::\n"; + let html = render(body, "", &["--profile", "advanced"]); + assert!(html.contains("BOTH"), "both conditions hold: {html}"); + let html = render(body, "", &[]); + assert!(!html.contains("BOTH"), "profile condition fails: {html}"); +} + +#[test] +fn bare_content_hidden_always_hides() { + let body = "::: {.content-hidden}\nNEVER\n:::\nvisible-text\n"; + let html = render(body, "", &[]); + assert!(!html.contains("NEVER")); + assert!(html.contains("visible-text")); +} + +#[test] +fn surviving_div_loses_condition_attributes() { + let body = "::: {.content-visible when-profile=\"advanced\"}\nKEPT\n:::\n"; + let html = render(body, "", &["--profile", "advanced"]); + assert!(html.contains("KEPT")); + assert!( + !html.contains("when-profile"), + "condition attributes must not leak into HTML: {html}" + ); +} + +#[test] +fn hidden_float_does_not_consume_a_crossref_number() { + let body = "::: {.content-hidden when-profile=\"prod\"}\n\ + ::: {#fig-first}\nhidden content\n\nHidden caption\n:::\n\ + :::\n\n\ + ::: {#fig-second}\nvisible content\n\nVisible caption\n:::\n\n\ + See @fig-second.\n"; + let html = render(body, "", &["--profile", "prod"]); + assert!( + html.contains("Figure 1") || html.contains("Figure 1"), + "the only visible figure must be number 1: {html}" + ); + assert!(!html.contains("Hidden caption"), "{html}"); +} + +#[test] +fn nested_conditionals_compose() { + let body = "::: {.content-visible when-format=\"html\"}\nouter\n\n\ + ::: {.content-visible when-profile=\"advanced\"}\ninner\n:::\n\ + :::\n"; + let html = render(body, "", &[]); + assert!(html.contains("outer")); + assert!(!html.contains("inner")); + let html = render(body, "", &["--profile", "advanced"]); + assert!(html.contains("outer") && html.contains("inner")); +} + +#[test] +fn misspelled_condition_attribute_warns() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write(root.join("_quarto.yml"), "project:\n type: default\n").unwrap(); + std::fs::write( + root.join("doc.qmd"), + "::: {.content-visible when-profil=\"x\"}\nBODY\n:::\n", + ) + .unwrap(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(root.join("doc.qmd")) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!(out.status.success(), "a typo warns, it does not abort"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Q-2-42") && stderr.contains("when-profil"), + "must warn about the unknown condition attribute: {stderr}" + ); +} diff --git a/crates/quarto/tests/integration/main.rs b/crates/quarto/tests/integration/main.rs index d37713b6a..70be0bdd3 100644 --- a/crates/quarto/tests/integration/main.rs +++ b/crates/quarto/tests/integration/main.rs @@ -4,12 +4,14 @@ pub mod attribution_cli_e2e; pub mod bootstrap_sh; pub mod coalesced_diagnostics; +pub mod conditional_content_cli; pub mod create; pub mod extension_config_spans; pub mod get_config_cli; pub mod json_errors; pub mod jupyter_kernel_cleanup_e2e; pub mod preview_cli; +pub mod project_profile_cli; pub mod render_cli_e2e; pub mod render_exit_codes; pub mod render_integration; diff --git a/crates/quarto/tests/integration/project_profile_cli.rs b/crates/quarto/tests/integration/project_profile_cli.rs new file mode 100644 index 000000000..6dda3d989 --- /dev/null +++ b/crates/quarto/tests/integration/project_profile_cli.rs @@ -0,0 +1,412 @@ +/* + * tests/integration/project_profile_cli.rs + * Copyright (c) 2026 Posit, PBC + * + * CLI end-to-end tests for project profiles (bd-fu16z22k, Phase 2). + */ + +//! `--profile` / `QUARTO_PROFILE` through the real `q2` binary. +//! +//! Every test scrubs `QUARTO_PROFILE` from the child environment so a +//! developer's shell cannot leak into assertions, then sets it +//! explicitly where the test wants it. (This is the end-to-end +//! coverage for the env-var glue noted in the Phase 1 plan entry.) + +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +const Q2_BIN: &str = env!("CARGO_BIN_EXE_q2"); + +/// A project whose `winner` key differs per profile, plus overlays +/// `a`/`b` for ordering tests and a `title` used by the render test. +fn make_fixture() -> TempDir { + let dir = TempDir::new().expect("temp dir"); + let root = dir.path(); + std::fs::write( + root.join("_quarto.yml"), + "project:\n type: default\nwinner: base\ntitle: Base Title\n", + ) + .unwrap(); + std::fs::write( + root.join("_quarto-prod.yml"), + "winner: prod\ntitle: Production Title\n", + ) + .unwrap(); + std::fs::write(root.join("_quarto-a.yml"), "winner: from-a\n").unwrap(); + std::fs::write(root.join("_quarto-b.yml"), "winner: from-b\n").unwrap(); + std::fs::write(root.join("index.qmd"), "# Hello\n\nBody text.\n").unwrap(); + dir +} + +/// Run `q2 get-config index.qmd winner` in `root` with the given +/// extra args and env; return (exit-ok, stdout, stderr). +fn get_winner(root: &Path, extra: &[&str], env: &[(&str, &str)]) -> (bool, String, String) { + let mut cmd = Command::new(Q2_BIN); + cmd.arg("get-config") + .arg(root.join("index.qmd")) + .arg("winner") + .args(extra) + .env_remove("QUARTO_PROFILE"); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("q2 runs"); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).trim().to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +// ── get-config: activation plumbing ───────────────────────────────── + +#[test] +fn get_config_without_profiles_sees_base() { + let dir = make_fixture(); + let (ok, stdout, _) = get_winner(dir.path(), &[], &[]); + assert!(ok); + assert_eq!(stdout, "\"base\""); +} + +#[test] +fn get_config_profile_flag_selects_overlay() { + let dir = make_fixture(); + let (ok, stdout, _) = get_winner(dir.path(), &["--profile", "prod"], &[]); + assert!(ok); + assert_eq!(stdout, "\"prod\""); +} + +#[test] +fn get_config_env_var_selects_overlay() { + let dir = make_fixture(); + let (ok, stdout, _) = get_winner(dir.path(), &[], &[("QUARTO_PROFILE", "prod")]); + assert!(ok); + assert_eq!(stdout, "\"prod\""); +} + +#[test] +fn profile_flag_replaces_env_var() { + // Q1 parity: --profile REPLACES QUARTO_PROFILE, never merges. + let dir = make_fixture(); + let (ok, stdout, _) = get_winner(dir.path(), &["--profile", "b"], &[("QUARTO_PROFILE", "a")]); + assert!(ok); + assert_eq!(stdout, "\"from-b\""); +} + +#[test] +fn comma_form_and_repeated_flags_are_equivalent() { + let dir = make_fixture(); + let (ok1, comma, _) = get_winner(dir.path(), &["--profile", "a,b"], &[]); + let (ok2, repeated, _) = get_winner(dir.path(), &["--profile", "a", "--profile", "b"], &[]); + assert!(ok1 && ok2); + assert_eq!(comma, "\"from-a\"", "first-listed profile wins"); + assert_eq!(repeated, comma); +} + +// ── render: e2e through the real pipeline ─────────────────────────── + +#[test] +fn render_profile_flag_changes_output() { + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("index.qmd")) + .args(["--profile", "prod", "--quiet"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!( + out.status.success(), + "render failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let html = std::fs::read_to_string(dir.path().join("index.html")).expect("output exists"); + assert!( + html.contains("Production Title"), + "the overlay title must reach the rendered HTML" + ); +} + +#[test] +fn render_project_dir_with_profile_flag() { + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path()) + .args(["--profile", "prod", "--quiet"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!( + out.status.success(), + "project render failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let html = std::fs::read_to_string(dir.path().join("index.html")).expect("output exists"); + assert!(html.contains("Production Title")); +} + +#[test] +fn render_verbose_echoes_active_profiles() { + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("index.qmd")) + .args(["--profile", "prod", "-v"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("prod") && stderr.to_lowercase().contains("profile"), + "-v must echo the active profile set; stderr: {stderr}" + ); +} + +#[test] +fn render_without_verbose_stays_quiet_about_profiles() { + // Q1 parity: normal output does not announce profiles. + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("index.qmd")) + .args(["--profile", "prod"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.to_lowercase().contains("active project profile"), + "no profile echo without -v; stderr: {stderr}" + ); +} + +// ── diagnostics through the binary ────────────────────────────────── + +#[test] +fn unknown_profile_warns_but_renders() { + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("index.qmd")) + .args(["--profile", "produciton"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!( + out.status.success(), + "an unknown profile warns, it does not abort: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Q-5-19") && stderr.contains("produciton"), + "stderr must carry the Q-5-19 warning naming the profile: {stderr}" + ); +} + +#[test] +fn invalid_profile_name_aborts_with_q_5_21() { + let dir = make_fixture(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("index.qmd")) + .args(["--profile", "bad/name"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!(!out.status.success(), "invalid profile names abort"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Q-5-21") && stderr.contains("bad/name"), + "stderr must carry Q-5-21 naming the offender: {stderr}" + ); +} + +#[test] +fn single_file_render_accepts_profile_without_warning() { + // No project ⇒ no overlays to match, so no Q-5-19 spam; the + // selection still resolves (conditional content consumes it in + // Phase 4). + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("solo.qmd"), "# Solo\n").unwrap(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("solo.qmd")) + .args(["--profile", "prod", "--quiet"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!( + out.status.success(), + "single-file render with --profile must work: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + !stderr.contains("Q-5-19"), + "no unknown-profile warning: {stderr}" + ); +} + +#[test] +fn single_file_render_still_validates_profile_names() { + // Strictness is not project-only: a bad name aborts even with no + // `_quarto.yml` anywhere. + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("solo.qmd"), "# Solo\n").unwrap(); + let out = Command::new(Q2_BIN) + .arg("render") + .arg(dir.path().join("solo.qmd")) + .args(["--profile", "bad/name"]) + .env_remove("QUARTO_PROFILE") + .output() + .expect("q2 runs"); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Q-5-21"), "got: {stderr}"); +} + +// ── environment-file integration (Phase 3, needs PR #486) ────────── + +/// Render `doc.qmd` in `root` with args/env; return the produced HTML. +fn render_doc(root: &Path, extra: &[&str], env: &[(&str, &str)]) -> (bool, String, String) { + let mut cmd = Command::new(Q2_BIN); + cmd.arg("render") + .arg(root.join("doc.qmd")) + .args(extra) + .env_remove("QUARTO_PROFILE"); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().expect("q2 runs"); + let html = std::fs::read_to_string(root.join("doc.html")).unwrap_or_default(); + ( + out.status.success(), + html, + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +#[test] +fn quarto_profile_in_environment_file_activates() { + // Q1's dotenv bootstrap: QUARTO_PROFILE defined in _environment + // selects profiles when neither --profile nor the env var do. + let dir = make_fixture(); + std::fs::write(dir.path().join("_environment"), "QUARTO_PROFILE=prod\n").unwrap(); + let (ok, stdout, _) = get_winner(dir.path(), &[], &[]); + assert!(ok); + assert_eq!(stdout, "\"prod\""); +} + +#[test] +fn environment_local_bootstrap_beats_base() { + let dir = make_fixture(); + std::fs::write(dir.path().join("_environment"), "QUARTO_PROFILE=a\n").unwrap(); + std::fs::write(dir.path().join("_environment.local"), "QUARTO_PROFILE=b\n").unwrap(); + let (ok, stdout, _) = get_winner(dir.path(), &[], &[]); + assert!(ok); + assert_eq!(stdout, "\"from-b\""); +} + +#[test] +fn real_env_and_cli_beat_environment_file_bootstrap() { + let dir = make_fixture(); + std::fs::write(dir.path().join("_environment"), "QUARTO_PROFILE=prod\n").unwrap(); + // Real env var wins over the file… + let (ok, stdout, _) = get_winner(dir.path(), &[], &[("QUARTO_PROFILE", "a")]); + assert!(ok); + assert_eq!(stdout, "\"from-a\""); + // …and --profile wins over both. + let (ok, stdout, _) = get_winner(dir.path(), &["--profile", "b"], &[("QUARTO_PROFILE", "a")]); + assert!(ok); + assert_eq!(stdout, "\"from-b\""); +} + +#[test] +fn profile_environment_files_layer_first_listed_wins() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write(root.join("_quarto.yml"), "project:\n type: default\n").unwrap(); + std::fs::write( + root.join("_environment"), + "GREETING=from-base\nBASE_ONLY=base\n", + ) + .unwrap(); + std::fs::write(root.join("_environment-a"), "GREETING=from-a\n").unwrap(); + std::fs::write(root.join("_environment-b"), "GREETING=from-b\nB_ONLY=b\n").unwrap(); + std::fs::write( + root.join("doc.qmd"), + "G={{< env GREETING none >}} BASE={{< env BASE_ONLY none >}} B={{< env B_ONLY none >}}\n", + ) + .unwrap(); + let (ok, html, stderr) = render_doc(root, &["--profile", "a,b", "--quiet"], &[]); + assert!(ok, "render failed: {stderr}"); + assert!( + html.contains("G=from-a"), + "first-listed profile's env file must win: {html}" + ); + assert!( + html.contains("BASE=base"), + "base _environment still applies: {html}" + ); + assert!( + html.contains("B=b"), + "later profiles still contribute new keys: {html}" + ); +} + +#[test] +fn environment_local_beats_profile_env_files() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + std::fs::write(root.join("_quarto.yml"), "project:\n type: default\n").unwrap(); + std::fs::write(root.join("_environment-prod"), "GREETING=from-prod\n").unwrap(); + std::fs::write(root.join("_environment.local"), "GREETING=from-local\n").unwrap(); + std::fs::write(root.join("doc.qmd"), "G={{< env GREETING none >}}\n").unwrap(); + let (ok, html, stderr) = render_doc(root, &["--profile", "prod", "--quiet"], &[]); + assert!(ok, "render failed: {stderr}"); + assert!( + html.contains("G=from-local"), + "_environment.local wins: {html}" + ); +} + +#[test] +fn quarto_profile_in_profile_env_file_does_not_recurse() { + // Q1 parity: the bootstrap reads _environment{,.local} only. A + // QUARTO_PROFILE inside _environment- must not activate + // more profiles. + let dir = make_fixture(); + std::fs::write(dir.path().join("_environment-a"), "QUARTO_PROFILE=b\n").unwrap(); + let (ok, stdout, _) = get_winner(dir.path(), &["--profile", "a"], &[]); + assert!(ok); + assert_eq!( + stdout, "\"from-a\"", + "profile b must NOT have been activated by _environment-a" + ); +} + +// ── flag presence on sibling commands ─────────────────────────────── + +#[test] +fn commands_advertise_profile_flag() { + // `preview` is deliberately absent: its flag form needs the + // selection threaded through HubContext (bd-pfgc273f); + // `QUARTO_PROFILE=x q2 preview` works today. + for subcommand in ["publish", "render", "get-config"] { + let out = Command::new(Q2_BIN) + .args([subcommand, "--help"]) + .output() + .expect("q2 runs"); + let help = String::from_utf8_lossy(&out.stdout); + assert!( + help.contains("--profile"), + "`q2 {subcommand} --help` must document --profile; got:\n{help}" + ); + } +} diff --git a/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto-prod.yml b/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto-prod.yml new file mode 100644 index 000000000..86a1c7ae6 --- /dev/null +++ b/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto-prod.yml @@ -0,0 +1,3 @@ +features: + beta: true +title: Production Title diff --git a/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto.yml b/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto.yml new file mode 100644 index 000000000..10c447279 --- /dev/null +++ b/crates/quarto/tests/smoke-all/metadata/project-profiles/_quarto.yml @@ -0,0 +1,5 @@ +project: + type: default + +profile: + default: prod diff --git a/crates/quarto/tests/smoke-all/metadata/project-profiles/index.qmd b/crates/quarto/tests/smoke-all/metadata/project-profiles/index.qmd new file mode 100644 index 000000000..7487a0da3 --- /dev/null +++ b/crates/quarto/tests/smoke-all/metadata/project-profiles/index.qmd @@ -0,0 +1,23 @@ +--- +_quarto: + tests: + html: + ensureFileRegexMatches: + - ["PROD-VISIBLE", "BETA-VISIBLE", "Production Title"] + - ["PROD-HIDDEN", "when-profile"] + noErrors: true +--- + +# Profile smoke + +::: {.content-visible when-profile="prod"} +PROD-VISIBLE +::: + +::: {.content-hidden when-profile="prod"} +PROD-HIDDEN +::: + +::: {.content-visible when-meta="features.beta"} +BETA-VISIBLE +::: diff --git a/docs/_quarto.yml b/docs/_quarto.yml index d3c62d39b..6c2370997 100644 --- a/docs/_quarto.yml +++ b/docs/_quarto.yml @@ -31,6 +31,7 @@ website: - guides/projects/render-list.qmd - guides/projects/scripts.qmd - guides/projects/environment.qmd + - guides/projects/profiles.qmd - guides/publishing/index.qmd - id: Authoring collapse-level: 1 diff --git a/docs/guides/projects/environment.qmd b/docs/guides/projects/environment.qmd index ddd9ab01f..d2652df8f 100644 --- a/docs/guides/projects/environment.qmd +++ b/docs/guides/projects/environment.qmd @@ -13,6 +13,7 @@ of your documents, and to configure the programs your project runs |------|---------| | `_environment` | Variables shared by everyone; check it into version control. | | `_environment.local` | Per-machine overrides (tokens, local paths); add it to `.gitignore`. | +| `_environment-` | Variables applied when the named [project profile](profiles.qmd) is active. | | `_environment.required` | Names that must be defined for the project to render correctly. | Each file holds `KEY=value` lines: @@ -38,7 +39,9 @@ A variable defined in several places resolves in this order: 1. the **real environment** — a variable exported in your shell (or set by CI) always wins; environment files never override it; 2. `_environment.local`; -3. `_environment`. +3. `_environment-` for each active + [project profile](profiles.qmd), first-listed profile first; +4. `_environment`. ## Where the variables are visible @@ -56,6 +59,27 @@ A variable defined in several places resolves in this order: Environment files are project-scoped: a single-file render outside any project (no `_quarto.yml`) does not load them. +## Profile environments + +With [project profiles](profiles.qmd), a profile named `production` +also reads `_environment-production`. Define only what varies: + +``` {.default filename="_environment-production"} +OMP_NUM_THREADS=16 +``` + +You can set `QUARTO_PROFILE` itself in `_environment` or +`_environment.local` to give the project a default profile; the +`--profile` option and a `QUARTO_PROFILE` exported in your shell +both override it. (Profile environment files cannot set +`QUARTO_PROFILE` — that would activate profiles recursively.) + +::: {.callout-important} +Don't put secrets in `_environment-` files — they are +typically checked into version control. Use `_environment.local` or +your CI system's secret store instead. +::: + ## Required variables List names in `_environment.required` to get a warning when a diff --git a/docs/guides/projects/profiles.qmd b/docs/guides/projects/profiles.qmd new file mode 100644 index 000000000..7ddf9da24 --- /dev/null +++ b/docs/guides/projects/profiles.qmd @@ -0,0 +1,177 @@ +--- +title: "Project Profiles" +--- + +Project profiles adapt a project's configuration, environment, and +content for different scenarios from a single source: render a +website with `freeze: true` locally but always execute on CI, build +a basic and an advanced version of the same material, or keep +production-only options out of your everyday preview. + +When a profile named `production` is active: + +1. `_quarto-production.yml` is merged over `_quarto.yml`; +2. variables from `_environment-production` are applied (see + [Project Environment Files](environment.qmd)); +3. content marked `when-profile="production"` appears (and + `unless-profile="production"` content disappears); +4. code cells and render scripts see + `QUARTO_PROFILE=production`, so Python/R code can branch on it. + +## Activating profiles + +Activate one or more profiles with the `--profile` option or the +`QUARTO_PROFILE` environment variable: + +``` {.bash} +quarto render --profile production +quarto render --profile advanced,production + +export QUARTO_PROFILE=advanced,production +quarto render +``` + +`--profile` *replaces* any `QUARTO_PROFILE` from the environment — +the two never merge. Profile names are comma-separated, must start +with a letter or digit, and may contain only letters, digits, `.`, +`_`, and `-` (they name files like `_quarto-.yml`; anything +else is an error). + +::: {.callout-note} +`quarto preview` currently reads `QUARTO_PROFILE` only: +`QUARTO_PROFILE=production quarto preview`. Restart the preview to +change profiles. +::: + +## Profile configuration + +A profile's configuration file `_quarto-.yml` sits next to +`_quarto.yml` and may contain any project configuration. It is +merged *over* the main configuration: + +``` {.yaml filename="_quarto.yml"} +project: + type: website + +execute: + freeze: true +``` + +``` {.yaml filename="_quarto-production.yml"} +execute: + freeze: false +``` + +Maps merge key-by-key and scalars from the profile win. Arrays +*concatenate*; write `key: !prefer [...]` in the profile to replace +an array instead. When several profiles are active, the +**first-listed** profile wins conflicts. A local override file +`_quarto.yml.local` (git-ignore it) is merged over everything, +profiles included. + +Selecting a profile that matches nothing in the project — no +`_quarto-.yml`, no `_environment-`, not declared under +`profile:` — is usually a typo and draws a warning. + +## Default profiles and groups + +The `profile` key in `_quarto.yml` controls what is active when no +explicit selection is made: + +``` {.yaml filename="_quarto.yml"} +profile: + default: advanced +``` + +A profile *group* declares mutually exclusive profiles of which +exactly one should always be active — the first member is the +group's default: + +``` {.yaml filename="_quarto.yml"} +project: + type: book + +book: + title: "My Book" + +profile: + group: + - [basic, advanced] +``` + +With `_quarto-basic.yml` and `_quarto-advanced.yml` each supplying +their own `book: chapters:` list, `quarto render` builds the basic +book and `quarto render --profile advanced` the advanced one — and +rendering with *no* profile still works, because the group activates +`basic`. + +`_quarto.yml.local` may also set `profile: default:` (handy for a +personal default on one machine); groups are read from `_quarto.yml` +only. You can also set `QUARTO_PROFILE=...` in `_environment` or +`_environment.local` to give the project a default. + +## Profile content + +Divs and spans with the `.content-visible` and `.content-hidden` +classes condition content on the active profiles: + +``` {.markdown} +::: {.content-visible when-profile="advanced"} +This content only appears in the advanced version. +::: + +::: {.content-hidden when-profile="advanced"} +This content is hidden from the advanced version. +::: + +An inline example: [only for advanced readers]{.content-visible +when-profile="advanced"}. +``` + +The same classes accept `when-format` / `unless-format` (for output +formats, with the usual aliases — `when-format="html"` also matches +`revealjs` and `epub`) and `when-meta` / `unless-meta` (true when +the metadata at a dotted path is present and not `false`). Multiple +conditions on one element must all hold; a comma-separated value +(`when-profile="basic,advanced"`) matches when *any* listed name is +active. Because profiles can set metadata in their configuration +files, `when-meta` combined with a profile overlay is often the most +flexible way to control content. + +::: {.callout-tip} +A profile used *only* for conditional content matches no files, so +it draws the unknown-profile warning. Declare it under +`profile.group` (or `profile.default`) in `_quarto.yml` to tell +Quarto it is intentional. +::: + +## Profiles in code + +The active profile list (comma-separated, in activation order, +including group defaults) is visible to everything the render runs: + +``` {.python} +import os +profiles = os.environ.get("QUARTO_PROFILE", "").split(",") +``` + +The same value is available in documents via +`{{{< env QUARTO_PROFILE >}}}`. + +## Differences from Quarto 1 + +Quarto 2 keeps Quarto 1's activation and merging rules, with these +deliberate changes: + +- **Typos are diagnosed.** Unknown active profiles, malformed + `profile:` configuration, invalid profile names, misspelled + `when-*`/`unless-*` attributes, and `profile:` keys in files where + they have no effect all produce warnings or errors; Quarto 1 is + silent on all of them. +- **Array merging concatenates** (use `!prefer` to replace); Quarto + 1 unioned arrays while removing duplicates. +- **Comma-separated condition values** (`when-profile="a,b"`) mean + *any-of*; Quarto 1 matched the value literally. +- `--profile` never modifies your environment; code sees + `QUARTO_PROFILE` because Quarto passes it to the processes it + starts.