Skip to content

Commit 89ffa4a

Browse files
committed
setup: Implement compile-time embedded config asset manifest
Generate embedded asset tables for OpenCode and Claude at build time and include file bytes directly in the CLI binary. Expose target-scoped asset iteration with normalized deterministic paths, and add tests for manifest completeness, ordering, and path normalization.
1 parent 0922c91 commit 89ffa4a

8 files changed

Lines changed: 326 additions & 8 deletions

File tree

cli/build.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use std::{
2+
env, fs,
3+
io::{self, Write},
4+
path::{Path, PathBuf},
5+
};
6+
7+
const TARGETS: &[TargetSpec] = &[
8+
TargetSpec {
9+
const_name: "OPENCODE_EMBEDDED_ASSETS",
10+
relative_root: "config/.opencode",
11+
include_prefix: "/../config/.opencode/",
12+
},
13+
TargetSpec {
14+
const_name: "CLAUDE_EMBEDDED_ASSETS",
15+
relative_root: "config/.claude",
16+
include_prefix: "/../config/.claude/",
17+
},
18+
];
19+
20+
struct TargetSpec {
21+
const_name: &'static str,
22+
relative_root: &'static str,
23+
include_prefix: &'static str,
24+
}
25+
26+
fn main() {
27+
if let Err(error) = generate_embedded_asset_manifest() {
28+
panic!("failed to generate setup embedded asset manifest: {error}");
29+
}
30+
}
31+
32+
fn generate_embedded_asset_manifest() -> io::Result<()> {
33+
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(invalid_data)?);
34+
let repository_root = manifest_dir
35+
.parent()
36+
.ok_or_else(|| invalid_data("CARGO_MANIFEST_DIR does not have a parent"))?
37+
.to_path_buf();
38+
let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(invalid_data)?);
39+
let destination_path = out_dir.join("setup_embedded_assets.rs");
40+
41+
let mut output = String::new();
42+
43+
for target in TARGETS {
44+
let source_root = repository_root.join(target.relative_root);
45+
println!("cargo:rerun-if-changed={}", source_root.display());
46+
47+
let mut files = Vec::new();
48+
collect_files(&source_root, &source_root, &mut files)?;
49+
files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path));
50+
51+
output.push_str(&format!(
52+
"pub static {}: &[EmbeddedAsset] = &[\n",
53+
target.const_name
54+
));
55+
56+
for file in &files {
57+
println!("cargo:rerun-if-changed={}", file.absolute_path.display());
58+
output.push_str(&format!(
59+
" EmbeddedAsset {{ relative_path: \"{}\", bytes: include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"{}{}\")) }},\n",
60+
escape_for_rust_string(&file.relative_path),
61+
target.include_prefix,
62+
escape_for_rust_string(&file.relative_path),
63+
));
64+
}
65+
66+
output.push_str("];\n\n");
67+
}
68+
69+
let mut output_file = fs::File::create(destination_path)?;
70+
output_file.write_all(output.as_bytes())
71+
}
72+
73+
#[derive(Debug)]
74+
struct SourceFile {
75+
absolute_path: PathBuf,
76+
relative_path: String,
77+
}
78+
79+
fn collect_files(
80+
base_root: &Path,
81+
current_dir: &Path,
82+
output: &mut Vec<SourceFile>,
83+
) -> io::Result<()> {
84+
for entry in fs::read_dir(current_dir)? {
85+
let entry = entry?;
86+
let path = entry.path();
87+
88+
if entry.file_type()?.is_dir() {
89+
collect_files(base_root, &path, output)?;
90+
continue;
91+
}
92+
93+
let relative_path = path
94+
.strip_prefix(base_root)
95+
.map_err(|_| invalid_data("failed to strip source root from file path"))?;
96+
97+
let relative_path = normalize_relative_path(relative_path)?;
98+
99+
output.push(SourceFile {
100+
absolute_path: path,
101+
relative_path,
102+
});
103+
}
104+
105+
Ok(())
106+
}
107+
108+
fn normalize_relative_path(path: &Path) -> io::Result<String> {
109+
let normalized = path
110+
.to_str()
111+
.ok_or_else(|| invalid_data("non-UTF-8 config paths are not supported"))?
112+
.replace('\\', "/");
113+
114+
if normalized.is_empty() {
115+
return Err(invalid_data("relative path cannot be empty"));
116+
}
117+
118+
if normalized.starts_with('/') {
119+
return Err(invalid_data("relative path must not start with '/'"));
120+
}
121+
122+
Ok(normalized)
123+
}
124+
125+
fn escape_for_rust_string(value: &str) -> String {
126+
value.replace('\\', "\\\\").replace('"', "\\\"")
127+
}
128+
129+
fn invalid_data<E: ToString>(error: E) -> io::Error {
130+
io::Error::new(io::ErrorKind::InvalidData, error.to_string())
131+
}

cli/src/services/setup.rs

Lines changed: 187 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,47 @@ pub enum SetupTarget {
1111
Both,
1212
}
1313

14+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15+
pub struct EmbeddedAsset {
16+
pub relative_path: &'static str,
17+
pub bytes: &'static [u8],
18+
}
19+
20+
include!(concat!(env!("OUT_DIR"), "/setup_embedded_assets.rs"));
21+
22+
pub enum EmbeddedAssetSelectionIter {
23+
One(std::slice::Iter<'static, EmbeddedAsset>),
24+
Both(
25+
std::iter::Chain<
26+
std::slice::Iter<'static, EmbeddedAsset>,
27+
std::slice::Iter<'static, EmbeddedAsset>,
28+
>,
29+
),
30+
}
31+
32+
impl Iterator for EmbeddedAssetSelectionIter {
33+
type Item = &'static EmbeddedAsset;
34+
35+
fn next(&mut self) -> Option<Self::Item> {
36+
match self {
37+
Self::One(iter) => iter.next(),
38+
Self::Both(iter) => iter.next(),
39+
}
40+
}
41+
}
42+
43+
pub fn iter_embedded_assets_for_setup_target(target: SetupTarget) -> EmbeddedAssetSelectionIter {
44+
match target {
45+
SetupTarget::OpenCode => EmbeddedAssetSelectionIter::One(OPENCODE_EMBEDDED_ASSETS.iter()),
46+
SetupTarget::Claude => EmbeddedAssetSelectionIter::One(CLAUDE_EMBEDDED_ASSETS.iter()),
47+
SetupTarget::Both => EmbeddedAssetSelectionIter::Both(
48+
OPENCODE_EMBEDDED_ASSETS
49+
.iter()
50+
.chain(CLAUDE_EMBEDDED_ASSETS.iter()),
51+
),
52+
}
53+
}
54+
1455
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1556
pub enum SetupMode {
1657
Interactive,
@@ -78,9 +119,15 @@ pub fn run_placeholder_setup_for_mode(mode: SetupMode) -> Result<String> {
78119
SetupMode::NonInteractive(SetupTarget::Both) => "--both",
79120
};
80121

122+
let selected_target = match mode {
123+
SetupMode::Interactive => SetupTarget::Both,
124+
SetupMode::NonInteractive(target) => target,
125+
};
126+
let embedded_asset_count = iter_embedded_assets_for_setup_target(selected_target).count();
127+
81128
Ok(format!(
82-
"TODO: '{NAME}' is planned and not implemented yet. Setup mode '{mode_label}' accepted; setup plan scaffolded with {} deferred step(s).",
83-
plan.tasks.len(),
129+
"TODO: '{NAME}' is planned and not implemented yet. Setup mode '{mode_label}' accepted; setup plan scaffolded with {} deferred step(s). Embedded asset manifest is ready with {embedded_asset_count} file(s).",
130+
plan.tasks.len()
84131
))
85132
}
86133

@@ -223,12 +270,18 @@ pub fn resolve_setup_mode(options: SetupCliOptions) -> Result<SetupMode> {
223270

224271
#[cfg(test)]
225272
mod tests {
273+
use std::{
274+
fs,
275+
path::{Path, PathBuf},
276+
};
277+
226278
use anyhow::Result;
227279

228280
use super::{
229-
parse_setup_cli_options, resolve_setup_dispatch, resolve_setup_mode,
230-
run_placeholder_setup_for_mode, setup_usage_text, PlaceholderSetupService, SetupCliOptions,
231-
SetupDispatch, SetupMode, SetupRequest, SetupService, SetupTarget,
281+
iter_embedded_assets_for_setup_target, parse_setup_cli_options, resolve_setup_dispatch,
282+
resolve_setup_mode, run_placeholder_setup_for_mode, setup_usage_text,
283+
PlaceholderSetupService, SetupCliOptions, SetupDispatch, SetupMode, SetupRequest,
284+
SetupService, SetupTarget,
232285
};
233286

234287
#[derive(Clone, Copy, Debug)]
@@ -341,4 +394,133 @@ mod tests {
341394
assert_eq!(dispatch, SetupDispatch::Cancelled);
342395
Ok(())
343396
}
397+
398+
#[test]
399+
fn embedded_manifest_paths_are_sorted_and_normalized() {
400+
for target in [SetupTarget::OpenCode, SetupTarget::Claude] {
401+
let assets = assets_for_target(target);
402+
403+
assert!(!assets.is_empty(), "embedded asset set should not be empty");
404+
405+
let paths: Vec<&str> = assets.iter().map(|asset| asset.relative_path).collect();
406+
assert_eq!(paths.len(), assets.len());
407+
408+
for asset in assets {
409+
assert!(!asset.relative_path.is_empty());
410+
assert!(!asset.relative_path.starts_with('/'));
411+
assert!(!asset.relative_path.contains('\\'));
412+
assert!(!asset.relative_path.starts_with("config/"));
413+
assert!(
414+
!asset.bytes.is_empty(),
415+
"embedded files should have content bytes"
416+
);
417+
}
418+
419+
let mut sorted = paths.clone();
420+
sorted.sort_unstable();
421+
assert_eq!(
422+
paths, sorted,
423+
"embedded paths should be deterministic and sorted"
424+
);
425+
}
426+
}
427+
428+
#[test]
429+
fn embedded_manifest_matches_runtime_config_tree() -> Result<()> {
430+
let opencode_expected =
431+
collect_runtime_relative_paths(runtime_target_root(SetupTarget::OpenCode))?;
432+
let claude_expected =
433+
collect_runtime_relative_paths(runtime_target_root(SetupTarget::Claude))?;
434+
435+
let opencode_actual: Vec<String> = assets_for_target(SetupTarget::OpenCode)
436+
.iter()
437+
.map(|asset| asset.relative_path.to_string())
438+
.collect();
439+
let claude_actual: Vec<String> = assets_for_target(SetupTarget::Claude)
440+
.iter()
441+
.map(|asset| asset.relative_path.to_string())
442+
.collect();
443+
444+
assert_eq!(opencode_actual, opencode_expected);
445+
assert_eq!(claude_actual, claude_expected);
446+
Ok(())
447+
}
448+
449+
#[test]
450+
fn embedded_setup_target_iterator_scopes_assets_per_target() {
451+
let opencode_count = assets_for_target(SetupTarget::OpenCode).len();
452+
let claude_count = assets_for_target(SetupTarget::Claude).len();
453+
454+
let iter_opencode_count =
455+
iter_embedded_assets_for_setup_target(SetupTarget::OpenCode).count();
456+
let iter_claude_count = iter_embedded_assets_for_setup_target(SetupTarget::Claude).count();
457+
let iter_both_count = iter_embedded_assets_for_setup_target(SetupTarget::Both).count();
458+
459+
assert_eq!(iter_opencode_count, opencode_count);
460+
assert_eq!(iter_claude_count, claude_count);
461+
assert_eq!(iter_both_count, opencode_count + claude_count);
462+
}
463+
464+
fn runtime_target_root(target: SetupTarget) -> PathBuf {
465+
let target_relative = match target {
466+
SetupTarget::OpenCode => "config/.opencode",
467+
SetupTarget::Claude => "config/.claude",
468+
SetupTarget::Both => unreachable!("both is not a concrete filesystem root"),
469+
};
470+
471+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
472+
.parent()
473+
.expect("cli crate should be nested under repository root")
474+
.join(target_relative)
475+
}
476+
477+
fn assets_for_target(target: SetupTarget) -> &'static [super::EmbeddedAsset] {
478+
match target {
479+
SetupTarget::OpenCode => super::OPENCODE_EMBEDDED_ASSETS,
480+
SetupTarget::Claude => super::CLAUDE_EMBEDDED_ASSETS,
481+
SetupTarget::Both => unreachable!("both is not a single embedded target"),
482+
}
483+
}
484+
485+
fn collect_runtime_relative_paths(root: PathBuf) -> Result<Vec<String>> {
486+
let mut files = Vec::new();
487+
collect_runtime_files(&root, &root, &mut files)?;
488+
489+
files.sort_unstable();
490+
491+
let stable_paths = files
492+
.into_iter()
493+
.map(|path| {
494+
path.to_str()
495+
.expect("runtime config path should be UTF-8")
496+
.replace('\\', "/")
497+
})
498+
.collect();
499+
500+
Ok(stable_paths)
501+
}
502+
503+
fn collect_runtime_files(
504+
base_root: &Path,
505+
current_dir: &Path,
506+
output: &mut Vec<PathBuf>,
507+
) -> Result<()> {
508+
for entry in fs::read_dir(current_dir)? {
509+
let entry = entry?;
510+
let path = entry.path();
511+
512+
if entry.file_type()?.is_dir() {
513+
collect_runtime_files(base_root, &path, output)?;
514+
continue;
515+
}
516+
517+
let relative = path
518+
.strip_prefix(base_root)
519+
.expect("relative path should be under root")
520+
.to_path_buf();
521+
output.push(relative);
522+
}
523+
524+
Ok(())
525+
}
344526
}

context/architecture.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

context/cli/placeholder-foundation.md

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

context/glossary.md

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)