1#![forbid(unsafe_code)]
2
3use std::path::Path;
4
5mod agent_context;
6mod asset_pipeline;
7pub mod figma_client;
8mod ui_spec;
9
10#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
11pub enum PipelineError {
12 #[error("unsupported feature: {0}")]
13 UnsupportedFeature(String),
14 #[error("unknown stage: {0}")]
15 UnknownStage(String),
16 #[error("io error: {0}")]
17 Io(String),
18 #[error("serialization error: {0}")]
19 Serialization(String),
20 #[error("fetch client error: {0}")]
21 FetchClient(String),
22 #[error("missing input artifact: {0}")]
23 MissingInputArtifact(String),
24 #[error("normalizer error: {0}")]
25 Normalizer(String),
26 #[error("ui spec build error: {0}")]
27 UiSpecBuild(String),
28}
29
30impl PipelineError {
31 pub fn actionable_message(&self) -> String {
32 match self {
33 Self::UnknownStage(stage) => format!(
34 "unknown stage: {stage}. Valid stages: {}. Run `specloom stages` to list stage output directories.",
35 pipeline_stage_names().join(", ")
36 ),
37 Self::MissingInputArtifact(artifact_path) => {
38 if let Some(stage_name) = producer_stage_for_artifact(artifact_path.as_str()) {
39 format!(
40 "missing input artifact: {artifact_path}. Run `specloom run-stage {stage_name}` first, or run `specloom generate` to execute the full pipeline."
41 )
42 } else {
43 format!(
44 "missing input artifact: {artifact_path}. Run `specloom generate` to execute the full pipeline."
45 )
46 }
47 }
48 Self::Io(details) => format!(
49 "io error: {details}. Check that the working directory is writable and that `output/` is a directory."
50 ),
51 Self::Serialization(details) => format!(
52 "serialization error: {details}. Delete stale artifacts under `output/` and rerun the upstream stage."
53 ),
54 Self::FetchClient(details) => format!(
55 "fetch client error: {details}. For live fetch, verify `--input live`, `--file-key`, `--node-id`, and `FIGMA_TOKEN` (or `--figma-token`), then confirm file and node permissions in Figma."
56 ),
57 _ => self.to_string(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct PipelineStageDefinition {
64 pub name: &'static str,
65 pub output_dir: &'static str,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct StageExecutionResult {
70 pub stage_name: &'static str,
71 pub output_dir: &'static str,
72 pub artifact_path: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct PipelineRunConfig {
77 pub fetch_mode: FetchMode,
78}
79
80impl Default for PipelineRunConfig {
81 fn default() -> Self {
82 Self {
83 fetch_mode: FetchMode::Fixture,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum FetchMode {
90 Fixture,
91 Live(LiveFetchConfig),
92 Snapshot(SnapshotFetchConfig),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct LiveFetchConfig {
97 pub file_key: String,
98 pub node_id: String,
99 pub figma_token: String,
100 pub api_base_url: Option<String>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SnapshotFetchConfig {
105 pub snapshot_path: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum FindNodesStatus {
111 Ok,
112 LowConfidence,
113 NoMatch,
114 Ambiguous,
115}
116
117#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
118pub struct FindNodeCandidate {
119 pub node_id: String,
120 pub score: f32,
121 pub match_reasons: Vec<String>,
122}
123
124#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
125pub struct FindNodesResult {
126 pub status: FindNodesStatus,
127 pub candidates: Vec<FindNodeCandidate>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum NodeInfoStatus {
133 Ok,
134 NotFound,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
138pub struct NodeInfo {
139 pub node_id: String,
140 pub name: String,
141 pub node_type: String,
142 pub path: String,
143 pub raw_tokens: Vec<String>,
144 pub normalized_tokens: Vec<String>,
145 pub aliases: Vec<String>,
146 pub geometry_tags: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150pub struct NodeInfoResult {
151 pub status: NodeInfoStatus,
152 pub node: Option<NodeInfo>,
153}
154
155const FETCH_ARTIFACT_RELATIVE_PATH: &str = "output/raw/fetch_snapshot.json";
156const NORMALIZED_ARTIFACT_RELATIVE_PATH: &str = "output/normalized/normalized_document.json";
157const SPEC_ARTIFACT_RELATIVE_PATH: &str = "output/specs/ui_spec.ron";
158const PRE_LAYOUT_ARTIFACT_RELATIVE_PATH: &str = "output/specs/pre_layout.ron";
159const NODE_MAP_ARTIFACT_RELATIVE_PATH: &str = "output/specs/node_map.json";
160const TRANSFORM_PLAN_ARTIFACT_RELATIVE_PATH: &str = "output/specs/transform_plan.json";
161const AGENT_CONTEXT_ARTIFACT_RELATIVE_PATH: &str = "output/agent/agent_context.json";
162const SEARCH_INDEX_ARTIFACT_RELATIVE_PATH: &str = "output/agent/search_index.json";
163const GENERATION_WARNINGS_ARTIFACT_RELATIVE_PATH: &str = "output/reports/generation_warnings.json";
164const GENERATION_TRACE_ARTIFACT_RELATIVE_PATH: &str = "output/reports/generation_trace.json";
165const ASSET_MANIFEST_RELATIVE_PATH: &str = "output/assets/asset_manifest.json";
166
167const FETCH_FIXTURE_FILE_KEY: &str = "fixture-file-key";
168const FETCH_FIXTURE_NODE_ID: &str = "0:1";
169const FETCH_FIXTURE_JSON: &str = r#"{
170 "document": {
171 "id": "0:1",
172 "name": "Fixture Root",
173 "type": "FRAME",
174 "children": []
175 }
176}"#;
177
178fn producer_stage_for_artifact(artifact_path: &str) -> Option<&'static str> {
179 match artifact_path {
180 FETCH_ARTIFACT_RELATIVE_PATH => Some("fetch"),
181 NORMALIZED_ARTIFACT_RELATIVE_PATH => Some("normalize"),
182 SPEC_ARTIFACT_RELATIVE_PATH => Some("build-spec"),
183 PRE_LAYOUT_ARTIFACT_RELATIVE_PATH => Some("build-spec"),
184 NODE_MAP_ARTIFACT_RELATIVE_PATH => Some("build-spec"),
185 TRANSFORM_PLAN_ARTIFACT_RELATIVE_PATH => Some("build-spec"),
186 AGENT_CONTEXT_ARTIFACT_RELATIVE_PATH => Some("build-agent-context"),
187 SEARCH_INDEX_ARTIFACT_RELATIVE_PATH => Some("build-agent-context"),
188 ASSET_MANIFEST_RELATIVE_PATH => Some("export-assets"),
189 _ => None,
190 }
191}
192
193const PIPELINE_STAGES: [PipelineStageDefinition; 5] = [
194 PipelineStageDefinition {
195 name: "fetch",
196 output_dir: "output/raw",
197 },
198 PipelineStageDefinition {
199 name: "normalize",
200 output_dir: "output/normalized",
201 },
202 PipelineStageDefinition {
203 name: "build-spec",
204 output_dir: "output/specs",
205 },
206 PipelineStageDefinition {
207 name: "build-agent-context",
208 output_dir: "output/agent",
209 },
210 PipelineStageDefinition {
211 name: "export-assets",
212 output_dir: "output/assets",
213 },
214];
215
216const DEFAULT_RUN_ALL_STAGE_NAMES: [&str; 5] = [
217 "fetch",
218 "normalize",
219 "build-spec",
220 "build-agent-context",
221 "export-assets",
222];
223
224pub fn pipeline_stage_names() -> Vec<&'static str> {
225 PIPELINE_STAGES.iter().map(|stage| stage.name).collect()
226}
227
228pub fn pipeline_stage_output_dirs() -> Vec<(&'static str, &'static str)> {
229 PIPELINE_STAGES
230 .iter()
231 .map(|stage| (stage.name, stage.output_dir))
232 .collect()
233}
234
235pub fn run_stage(stage_name: &str) -> Result<StageExecutionResult, PipelineError> {
236 run_stage_with_config(stage_name, &PipelineRunConfig::default())
237}
238
239pub fn run_stage_with_config(
240 stage_name: &str,
241 config: &PipelineRunConfig,
242) -> Result<StageExecutionResult, PipelineError> {
243 let workspace_root = std::env::current_dir().map_err(io_error)?;
244 run_stage_in_workspace_with_config(stage_name, workspace_root.as_path(), config)
245}
246
247pub fn run_all() -> Result<Vec<StageExecutionResult>, PipelineError> {
248 run_all_with_config(&PipelineRunConfig::default())
249}
250
251pub fn run_all_with_config(
252 config: &PipelineRunConfig,
253) -> Result<Vec<StageExecutionResult>, PipelineError> {
254 let workspace_root = std::env::current_dir().map_err(io_error)?;
255 run_all_in_workspace_with_config(workspace_root.as_path(), config)
256}
257
258pub fn find_nodes(query: &str, top_k: usize) -> Result<FindNodesResult, PipelineError> {
259 let workspace_root = std::env::current_dir().map_err(io_error)?;
260 find_nodes_in_workspace(workspace_root.as_path(), query, top_k)
261}
262
263pub fn find_nodes_in_workspace(
264 workspace_root: &Path,
265 query: &str,
266 top_k: usize,
267) -> Result<FindNodesResult, PipelineError> {
268 let search_index = read_required_json::<agent_context::SearchIndex>(
269 workspace_root,
270 SEARCH_INDEX_ARTIFACT_RELATIVE_PATH,
271 )?;
272 let result = agent_context::rank_candidates(query, search_index.entries.as_slice(), top_k);
273
274 let status = map_search_status(result.status);
275 let candidates = result
276 .matches
277 .into_iter()
278 .map(|candidate| FindNodeCandidate {
279 node_id: candidate.node_id,
280 score: candidate.score,
281 match_reasons: candidate.match_reasons,
282 })
283 .collect::<Vec<_>>();
284
285 let candidate_node_ids = candidates
286 .iter()
287 .map(|candidate| candidate.node_id.clone())
288 .collect::<Vec<_>>();
289 append_trace_event(
290 workspace_root,
291 "find_nodes",
292 find_nodes_status_label(&status),
293 query,
294 candidate_node_ids.clone(),
295 )?;
296
297 match status {
298 FindNodesStatus::NoMatch => {
299 append_warning(
300 workspace_root,
301 "NODE_NOT_FOUND",
302 query,
303 Vec::new(),
304 "continue_with_best_effort",
305 "No node candidate found for query",
306 )?;
307 }
308 FindNodesStatus::LowConfidence => {
309 append_warning(
310 workspace_root,
311 "LOW_CONFIDENCE_MATCH",
312 query,
313 candidate_node_ids.clone(),
314 "continue_with_best_effort",
315 "Top node candidate confidence is below threshold",
316 )?;
317 }
318 FindNodesStatus::Ambiguous => {
319 append_warning(
320 workspace_root,
321 "MULTIPLE_CANDIDATES",
322 query,
323 candidate_node_ids.clone(),
324 "continue_with_best_effort",
325 "Multiple close candidates found for query",
326 )?;
327 }
328 FindNodesStatus::Ok => {}
329 }
330
331 Ok(FindNodesResult { status, candidates })
332}
333
334pub fn get_node_info(node_id: &str) -> Result<NodeInfoResult, PipelineError> {
335 let workspace_root = std::env::current_dir().map_err(io_error)?;
336 get_node_info_in_workspace(workspace_root.as_path(), node_id)
337}
338
339pub fn get_node_info_in_workspace(
340 workspace_root: &Path,
341 node_id: &str,
342) -> Result<NodeInfoResult, PipelineError> {
343 let search_index = read_required_json::<agent_context::SearchIndex>(
344 workspace_root,
345 SEARCH_INDEX_ARTIFACT_RELATIVE_PATH,
346 )?;
347
348 let maybe_entry = search_index
349 .entries
350 .into_iter()
351 .find(|entry| entry.node_id == node_id);
352 if let Some(entry) = maybe_entry {
353 append_trace_event(
354 workspace_root,
355 "get_node_info",
356 node_info_status_label(&NodeInfoStatus::Ok),
357 node_id,
358 vec![entry.node_id.clone()],
359 )?;
360 return Ok(NodeInfoResult {
361 status: NodeInfoStatus::Ok,
362 node: Some(NodeInfo {
363 node_id: entry.node_id,
364 name: entry.name,
365 node_type: entry.node_type,
366 path: entry.path,
367 raw_tokens: entry.raw_tokens,
368 normalized_tokens: entry.normalized_tokens,
369 aliases: entry.aliases,
370 geometry_tags: entry.geometry_tags,
371 }),
372 });
373 }
374
375 append_trace_event(
376 workspace_root,
377 "get_node_info",
378 node_info_status_label(&NodeInfoStatus::NotFound),
379 node_id,
380 Vec::new(),
381 )?;
382 append_warning(
383 workspace_root,
384 "NODE_NOT_FOUND",
385 node_id,
386 Vec::new(),
387 "continue_with_best_effort",
388 "Node ID was not found in search index",
389 )?;
390
391 Ok(NodeInfoResult {
392 status: NodeInfoStatus::NotFound,
393 node: None,
394 })
395}
396
397pub fn run_all_in_workspace(
398 workspace_root: &Path,
399) -> Result<Vec<StageExecutionResult>, PipelineError> {
400 run_all_in_workspace_with_config(workspace_root, &PipelineRunConfig::default())
401}
402
403pub fn run_all_in_workspace_with_config(
404 workspace_root: &Path,
405 config: &PipelineRunConfig,
406) -> Result<Vec<StageExecutionResult>, PipelineError> {
407 DEFAULT_RUN_ALL_STAGE_NAMES
408 .iter()
409 .map(|stage_name| run_stage_in_workspace_with_config(stage_name, workspace_root, config))
410 .collect()
411}
412
413pub fn run_stage_in_workspace(
414 stage_name: &str,
415 workspace_root: &Path,
416) -> Result<StageExecutionResult, PipelineError> {
417 run_stage_in_workspace_with_config(stage_name, workspace_root, &PipelineRunConfig::default())
418}
419
420pub fn run_stage_in_workspace_with_config(
421 stage_name: &str,
422 workspace_root: &Path,
423 config: &PipelineRunConfig,
424) -> Result<StageExecutionResult, PipelineError> {
425 let output = match stage_name {
426 "fetch" => Some(run_fetch_stage(workspace_root, &config.fetch_mode)?),
427 "normalize" => Some(run_normalize_stage(workspace_root)?),
428 "build-spec" => Some(run_build_spec_stage(workspace_root)?),
429 "build-agent-context" => Some(run_build_agent_context_stage(workspace_root, config)?),
430 "export-assets" => Some(run_export_assets_stage(workspace_root)?),
431 _ => None,
432 };
433
434 let stage = PIPELINE_STAGES
435 .iter()
436 .find(|candidate| candidate.name == stage_name)
437 .ok_or_else(|| PipelineError::UnknownStage(stage_name.to_string()))?;
438
439 Ok(StageExecutionResult {
440 stage_name: stage.name,
441 output_dir: stage.output_dir,
442 artifact_path: output,
443 })
444}
445
446fn run_fetch_stage(workspace_root: &Path, fetch_mode: &FetchMode) -> Result<String, PipelineError> {
447 let snapshot = match fetch_mode {
448 FetchMode::Fixture => fetch_fixture_snapshot()?,
449 FetchMode::Live(config) => fetch_live_snapshot(config)?,
450 FetchMode::Snapshot(config) => load_snapshot_from_file(workspace_root, config)?,
451 };
452
453 let artifact_path = workspace_root.join(FETCH_ARTIFACT_RELATIVE_PATH);
454 write_bytes(
455 artifact_path.as_path(),
456 serde_json::to_vec_pretty(&snapshot)
457 .map_err(serialization_error)?
458 .as_slice(),
459 )?;
460
461 Ok(normalize_result_path(
462 workspace_root,
463 artifact_path.as_path(),
464 ))
465}
466
467fn fetch_fixture_snapshot() -> Result<figma_client::RawFigmaSnapshot, PipelineError> {
468 let request = figma_client::FetchNodesRequest::new(
469 FETCH_FIXTURE_FILE_KEY.to_string(),
470 FETCH_FIXTURE_NODE_ID.to_string(),
471 )
472 .map_err(fetch_client_error)?;
473
474 figma_client::fetch_snapshot_from_fixture(&request, FETCH_FIXTURE_JSON)
475 .map_err(fetch_client_error)
476}
477
478fn fetch_live_snapshot(
479 config: &LiveFetchConfig,
480) -> Result<figma_client::RawFigmaSnapshot, PipelineError> {
481 let request = figma_client::LiveFetchRequest::new(
482 config.file_key.clone(),
483 config.node_id.clone(),
484 config.figma_token.clone(),
485 config.api_base_url.clone(),
486 )
487 .map_err(fetch_client_error)?;
488
489 figma_client::fetch_snapshot_live(&request).map_err(fetch_client_error)
490}
491
492fn load_snapshot_from_file(
493 workspace_root: &Path,
494 config: &SnapshotFetchConfig,
495) -> Result<figma_client::RawFigmaSnapshot, PipelineError> {
496 let snapshot_path = resolve_workspace_path(workspace_root, config.snapshot_path.as_str());
497 let bytes = std::fs::read(snapshot_path.as_path()).map_err(io_error)?;
498 serde_json::from_slice::<figma_client::RawFigmaSnapshot>(&bytes).map_err(serialization_error)
499}
500
501fn resolve_workspace_path(workspace_root: &Path, candidate_path: &str) -> std::path::PathBuf {
502 let path = Path::new(candidate_path);
503 if path.is_absolute() {
504 path.to_path_buf()
505 } else {
506 workspace_root.join(path)
507 }
508}
509
510fn run_normalize_stage(workspace_root: &Path) -> Result<String, PipelineError> {
511 let snapshot = read_required_json::<figma_client::RawFigmaSnapshot>(
512 workspace_root,
513 FETCH_ARTIFACT_RELATIVE_PATH,
514 )?;
515
516 let normalized =
517 crate::figma_client::normalizer::normalize_snapshot(&snapshot).map_err(normalizer_error)?;
518 let output_path = workspace_root.join(NORMALIZED_ARTIFACT_RELATIVE_PATH);
519 write_bytes(
520 output_path.as_path(),
521 serde_json::to_vec_pretty(&normalized)
522 .map_err(serialization_error)?
523 .as_slice(),
524 )?;
525
526 Ok(normalize_result_path(workspace_root, output_path.as_path()))
527}
528
529fn run_build_spec_stage(workspace_root: &Path) -> Result<String, PipelineError> {
530 let normalized = read_required_json::<crate::figma_client::normalizer::NormalizationOutput>(
531 workspace_root,
532 NORMALIZED_ARTIFACT_RELATIVE_PATH,
533 )?;
534 let pre_layout = ui_spec::build_pre_layout_spec(&normalized).map_err(ui_spec_build_error)?;
535 let pre_layout_encoded = pre_layout
536 .to_pretty_ron()
537 .map_err(|err| PipelineError::Serialization(err.to_string()))?;
538
539 let pre_layout_path = workspace_root.join(PRE_LAYOUT_ARTIFACT_RELATIVE_PATH);
540 write_bytes(pre_layout_path.as_path(), pre_layout_encoded.as_bytes())?;
541
542 let node_map_path = workspace_root.join(NODE_MAP_ARTIFACT_RELATIVE_PATH);
543 let node_map = build_node_map_artifact(&normalized).map_err(serialization_error)?;
544 let node_map_bytes = serde_json::to_vec_pretty(&node_map).map_err(serialization_error)?;
545 write_bytes(node_map_path.as_path(), node_map_bytes.as_slice())?;
546
547 let transform_plan = generate_transform_plan(workspace_root, &pre_layout, &node_map)?;
548 let transform_plan_path = workspace_root.join(TRANSFORM_PLAN_ARTIFACT_RELATIVE_PATH);
549 let transform_plan_bytes =
550 serde_json::to_vec_pretty(&transform_plan).map_err(serialization_error)?;
551 write_bytes(
552 transform_plan_path.as_path(),
553 transform_plan_bytes.as_slice(),
554 )?;
555
556 let spec =
557 ui_spec::apply_transform_plan(&pre_layout, &transform_plan).map_err(ui_spec_build_error)?;
558 let encoded = spec
559 .to_pretty_ron()
560 .map_err(|err| PipelineError::Serialization(err.to_string()))?;
561
562 let output_path = workspace_root.join(SPEC_ARTIFACT_RELATIVE_PATH);
563 write_bytes(output_path.as_path(), encoded.as_bytes())?;
564
565 Ok(normalize_result_path(workspace_root, output_path.as_path()))
566}
567
568#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
569#[serde(deny_unknown_fields)]
570struct NodeMapArtifact {
571 version: String,
572 nodes: std::collections::BTreeMap<String, serde_json::Value>,
573}
574
575fn build_node_map_artifact(
576 normalized: &crate::figma_client::normalizer::NormalizationOutput,
577) -> Result<NodeMapArtifact, serde_json::Error> {
578 let mut nodes = std::collections::BTreeMap::new();
579 for node in &normalized.document.nodes {
580 nodes.insert(node.id.clone(), serde_json::to_value(node)?);
581 }
582
583 Ok(NodeMapArtifact {
584 version: "node_map/1.0".to_string(),
585 nodes,
586 })
587}
588
589fn generate_transform_plan(
590 workspace_root: &Path,
591 _pre_layout: &ui_spec::UiSpec,
592 _node_map: &NodeMapArtifact,
593) -> Result<ui_spec::TransformPlan, PipelineError> {
594 let transform_plan_path = workspace_root.join(TRANSFORM_PLAN_ARTIFACT_RELATIVE_PATH);
595 if transform_plan_path.exists() {
596 let bytes = std::fs::read(transform_plan_path.as_path()).map_err(io_error)?;
597 return serde_json::from_slice::<ui_spec::TransformPlan>(bytes.as_slice())
598 .map_err(serialization_error);
599 }
600
601 Ok(ui_spec::TransformPlan::default())
602}
603
604fn run_build_agent_context_stage(
605 workspace_root: &Path,
606 config: &PipelineRunConfig,
607) -> Result<String, PipelineError> {
608 let spec = read_required_ron::<ui_spec::UiSpec>(workspace_root, SPEC_ARTIFACT_RELATIVE_PATH)?;
609
610 let root_node_id = spec.id().to_string();
611 let root_screenshot_ref = format!("output/images/root_{}.png", root_node_id.replace(':', "_"));
612
613 maybe_write_root_screenshot(
614 workspace_root,
615 config,
616 root_node_id.as_str(),
617 root_screenshot_ref.as_str(),
618 )?;
619
620 let context = agent_context::AgentContext {
621 version: "agent_context/1.0".to_string(),
622 screen: agent_context::ScreenRef {
623 root_node_id: root_node_id.clone(),
624 root_screenshot_ref,
625 },
626 rules: agent_context::GenerationRules {
627 on_node_mismatch: "warn_and_continue".to_string(),
628 },
629 tools: vec![
630 "find_nodes".to_string(),
631 "get_node_info".to_string(),
632 "get_node_screenshot".to_string(),
633 "get_asset".to_string(),
634 ],
635 skeleton: build_skeleton_nodes(&spec),
636 };
637
638 let search_index = agent_context::SearchIndex {
639 version: "search_index/1.0".to_string(),
640 entries: build_search_index_entries(&spec),
641 };
642
643 let context_path = workspace_root.join(AGENT_CONTEXT_ARTIFACT_RELATIVE_PATH);
644 let context_bytes = context.to_pretty_json().map_err(serialization_error)?;
645 write_bytes(context_path.as_path(), context_bytes.as_slice())?;
646
647 let index_path = workspace_root.join(SEARCH_INDEX_ARTIFACT_RELATIVE_PATH);
648 let index_bytes = serde_json::to_vec_pretty(&search_index).map_err(serialization_error)?;
649 write_bytes(index_path.as_path(), index_bytes.as_slice())?;
650
651 Ok(normalize_result_path(
652 workspace_root,
653 context_path.as_path(),
654 ))
655}
656
657fn maybe_write_root_screenshot(
658 workspace_root: &Path,
659 config: &PipelineRunConfig,
660 root_node_id: &str,
661 root_screenshot_ref: &str,
662) -> Result<(), PipelineError> {
663 let screenshot_path = workspace_root.join(root_screenshot_ref);
664 if screenshot_path.is_file() {
665 return Ok(());
666 }
667
668 let live_config = match &config.fetch_mode {
669 FetchMode::Live(config) => config,
670 _ => return Ok(()),
671 };
672
673 let request = figma_client::LiveScreenshotRequest::new(
674 live_config.file_key.clone(),
675 root_node_id.to_string(),
676 live_config.figma_token.clone(),
677 live_config.api_base_url.clone(),
678 )
679 .map_err(fetch_client_error)?;
680 let screenshot =
681 figma_client::fetch_node_screenshot_live(&request).map_err(fetch_client_error)?;
682
683 let response = reqwest::blocking::Client::new()
684 .get(screenshot.image_url.as_str())
685 .send()
686 .map_err(|err| {
687 PipelineError::FetchClient(format!("screenshot download transport error: {err}"))
688 })?;
689 let status = response.status();
690 if !status.is_success() {
691 let body = response
692 .text()
693 .unwrap_or_else(|_| "response body unavailable".to_string());
694 return Err(PipelineError::FetchClient(format!(
695 "screenshot download returned non-success status {}: {body}",
696 status.as_u16()
697 )));
698 }
699 let bytes = response.bytes().map_err(|err| {
700 PipelineError::FetchClient(format!("screenshot download decode error: {err}"))
701 })?;
702
703 write_bytes(screenshot_path.as_path(), bytes.as_ref())
704}
705
706fn build_skeleton_nodes(root: &ui_spec::UiSpec) -> Vec<agent_context::SkeletonNode> {
707 let mut nodes = Vec::new();
708 flatten_skeleton(root, "", &mut nodes);
709 nodes
710}
711
712fn flatten_skeleton(
713 node: &ui_spec::UiSpec,
714 parent_path: &str,
715 out: &mut Vec<agent_context::SkeletonNode>,
716) {
717 let name = node_name(node).to_string();
718 let path = if parent_path.is_empty() {
719 name.clone()
720 } else {
721 format!("{parent_path}/{name}")
722 };
723
724 out.push(agent_context::SkeletonNode {
725 node_id: node.id().to_string(),
726 node_type: node_type_label(node).to_string(),
727 name: name.clone(),
728 path: path.clone(),
729 children: node
730 .children()
731 .iter()
732 .map(|child| child.id().to_string())
733 .collect(),
734 });
735
736 for child in node.children() {
737 flatten_skeleton(child, path.as_str(), out);
738 }
739}
740
741fn build_search_index_entries(root: &ui_spec::UiSpec) -> Vec<agent_context::SearchIndexEntry> {
742 let mut entries = Vec::new();
743 flatten_search_entries(root, "", &mut entries);
744 entries
745}
746
747fn flatten_search_entries(
748 node: &ui_spec::UiSpec,
749 parent_path: &str,
750 out: &mut Vec<agent_context::SearchIndexEntry>,
751) {
752 let name = node_name(node).to_string();
753 let path = if parent_path.is_empty() {
754 name.clone()
755 } else {
756 format!("{parent_path}/{name}")
757 };
758
759 let mut raw_tokens = vec![name.clone()];
760 if let ui_spec::UiSpec::Container { text, .. } = node
761 && !text.is_empty()
762 {
763 raw_tokens.push(text.clone());
764 }
765
766 let mut normalized_tokens = std::collections::BTreeSet::new();
767 for token in raw_tokens
768 .iter()
769 .flat_map(|value| agent_context::normalize_tokens(value))
770 {
771 normalized_tokens.insert(token);
772 }
773
774 let geometry_tags = infer_geometry_tags(path.as_str());
775
776 out.push(agent_context::SearchIndexEntry {
777 node_id: node.id().to_string(),
778 name: name.clone(),
779 node_type: node_type_label(node).to_string(),
780 path: path.clone(),
781 raw_tokens,
782 normalized_tokens: normalized_tokens.into_iter().collect(),
783 aliases: Vec::new(),
784 geometry_tags,
785 });
786
787 for child in node.children() {
788 flatten_search_entries(child, path.as_str(), out);
789 }
790}
791
792fn infer_geometry_tags(path: &str) -> Vec<String> {
793 let path_tokens = agent_context::normalize_tokens(path);
794 let mut tags = std::collections::BTreeSet::new();
795 for token in path_tokens {
796 if matches!(
797 token.as_str(),
798 "header" | "footer" | "sidebar" | "left" | "right" | "center" | "body" | "content"
799 ) {
800 tags.insert(token);
801 }
802 }
803 tags.into_iter().collect()
804}
805
806fn node_name(node: &ui_spec::UiSpec) -> &str {
807 match node {
808 ui_spec::UiSpec::Container { name, .. }
809 | ui_spec::UiSpec::Instance { name, .. }
810 | ui_spec::UiSpec::Text { name, .. }
811 | ui_spec::UiSpec::Image { name, .. }
812 | ui_spec::UiSpec::Shape { name, .. }
813 | ui_spec::UiSpec::Vector { name, .. }
814 | ui_spec::UiSpec::Button { name, .. }
815 | ui_spec::UiSpec::ScrollView { name, .. }
816 | ui_spec::UiSpec::HStack { name, .. }
817 | ui_spec::UiSpec::VStack { name, .. }
818 | ui_spec::UiSpec::ZStack { name, .. } => name.as_str(),
819 }
820}
821
822fn node_type_label(node: &ui_spec::UiSpec) -> &'static str {
823 match node.node_type() {
824 ui_spec::NodeType::Container => "CONTAINER",
825 ui_spec::NodeType::Instance => "INSTANCE",
826 ui_spec::NodeType::Text => "TEXT",
827 ui_spec::NodeType::Image => "IMAGE",
828 ui_spec::NodeType::Shape => "SHAPE",
829 ui_spec::NodeType::Vector => "VECTOR",
830 ui_spec::NodeType::Button => "BUTTON",
831 ui_spec::NodeType::ScrollView => "SCROLL_VIEW",
832 ui_spec::NodeType::HStack => "HSTACK",
833 ui_spec::NodeType::VStack => "VSTACK",
834 ui_spec::NodeType::ZStack => "ZSTACK",
835 }
836}
837
838fn map_search_status(status: agent_context::SearchStatus) -> FindNodesStatus {
839 match status {
840 agent_context::SearchStatus::Ok => FindNodesStatus::Ok,
841 agent_context::SearchStatus::LowConfidence => FindNodesStatus::LowConfidence,
842 agent_context::SearchStatus::NoMatch => FindNodesStatus::NoMatch,
843 agent_context::SearchStatus::Ambiguous => FindNodesStatus::Ambiguous,
844 }
845}
846
847fn find_nodes_status_label(status: &FindNodesStatus) -> &'static str {
848 match status {
849 FindNodesStatus::Ok => "ok",
850 FindNodesStatus::LowConfidence => "low_confidence",
851 FindNodesStatus::NoMatch => "no_match",
852 FindNodesStatus::Ambiguous => "ambiguous",
853 }
854}
855
856fn node_info_status_label(status: &NodeInfoStatus) -> &'static str {
857 match status {
858 NodeInfoStatus::Ok => "ok",
859 NodeInfoStatus::NotFound => "not_found",
860 }
861}
862
863fn append_warning(
864 workspace_root: &Path,
865 warning_type: &str,
866 node_query: &str,
867 candidate_node_ids: Vec<String>,
868 agent_action: &str,
869 message: &str,
870) -> Result<(), PipelineError> {
871 let warnings_path = workspace_root.join(GENERATION_WARNINGS_ARTIFACT_RELATIVE_PATH);
872 let mut warnings = if warnings_path.exists() {
873 let bytes = std::fs::read(warnings_path.as_path()).map_err(io_error)?;
874 serde_json::from_slice::<agent_context::GenerationWarnings>(bytes.as_slice())
875 .map_err(serialization_error)?
876 } else {
877 agent_context::GenerationWarnings {
878 version: "generation_warnings/1.0".to_string(),
879 warnings: Vec::new(),
880 }
881 };
882
883 let next_id = format!("warning-{}", warnings.warnings.len() + 1);
884 warnings.warnings.push(agent_context::GenerationWarning {
885 warning_id: next_id,
886 warning_type: warning_type.to_string(),
887 severity: "warning".to_string(),
888 node_query: node_query.to_string(),
889 candidate_node_ids,
890 agent_action: agent_action.to_string(),
891 message: message.to_string(),
892 });
893
894 let encoded = serde_json::to_vec_pretty(&warnings).map_err(serialization_error)?;
895 write_bytes(warnings_path.as_path(), encoded.as_slice())
896}
897
898fn append_trace_event(
899 workspace_root: &Path,
900 tool_name: &str,
901 status: &str,
902 query: &str,
903 selected_node_ids: Vec<String>,
904) -> Result<(), PipelineError> {
905 let trace_path = workspace_root.join(GENERATION_TRACE_ARTIFACT_RELATIVE_PATH);
906 let mut trace = if trace_path.exists() {
907 let bytes = std::fs::read(trace_path.as_path()).map_err(io_error)?;
908 serde_json::from_slice::<agent_context::GenerationTrace>(bytes.as_slice())
909 .map_err(serialization_error)?
910 } else {
911 agent_context::GenerationTrace {
912 version: "generation_trace/1.0".to_string(),
913 events: Vec::new(),
914 }
915 };
916
917 let next_id = format!("event-{}", trace.events.len() + 1);
918 trace.events.push(agent_context::TraceEvent {
919 event_id: next_id,
920 tool_name: tool_name.to_string(),
921 status: status.to_string(),
922 query: query.to_string(),
923 selected_node_ids,
924 });
925
926 let encoded = serde_json::to_vec_pretty(&trace).map_err(serialization_error)?;
927 write_bytes(trace_path.as_path(), encoded.as_slice())
928}
929
930fn run_export_assets_stage(workspace_root: &Path) -> Result<String, PipelineError> {
931 let normalized = read_required_json::<crate::figma_client::normalizer::NormalizationOutput>(
932 workspace_root,
933 NORMALIZED_ARTIFACT_RELATIVE_PATH,
934 )?;
935
936 let assets = asset_pipeline::build_asset_manifest(&normalized);
937 let encoded = serde_json::to_vec_pretty(&assets).map_err(serialization_error)?;
938
939 let output_path = workspace_root.join(ASSET_MANIFEST_RELATIVE_PATH);
940 write_bytes(output_path.as_path(), encoded.as_slice())?;
941
942 Ok(normalize_result_path(workspace_root, output_path.as_path()))
943}
944
945fn read_required_json<T>(workspace_root: &Path, relative_path: &str) -> Result<T, PipelineError>
946where
947 T: serde::de::DeserializeOwned,
948{
949 let path = workspace_root.join(relative_path);
950 if !path.exists() {
951 return Err(PipelineError::MissingInputArtifact(
952 relative_path.to_string(),
953 ));
954 }
955
956 let bytes = std::fs::read(path.as_path()).map_err(io_error)?;
957 serde_json::from_slice(&bytes).map_err(serialization_error)
958}
959
960fn read_required_ron<T>(workspace_root: &Path, relative_path: &str) -> Result<T, PipelineError>
961where
962 T: serde::de::DeserializeOwned,
963{
964 let path = workspace_root.join(relative_path);
965 if !path.exists() {
966 return Err(PipelineError::MissingInputArtifact(
967 relative_path.to_string(),
968 ));
969 }
970
971 let text = std::fs::read_to_string(path.as_path()).map_err(io_error)?;
972 ron::de::from_str(text.as_str()).map_err(serialization_error)
973}
974
975fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PipelineError> {
976 if let Some(parent) = path.parent() {
977 std::fs::create_dir_all(parent).map_err(io_error)?;
978 }
979 std::fs::write(path, bytes).map_err(io_error)
980}
981
982fn normalize_result_path(workspace_root: &Path, path: &Path) -> String {
983 path.strip_prefix(workspace_root)
984 .ok()
985 .map(|relative| relative.display().to_string())
986 .unwrap_or_else(|| path.display().to_string())
987}
988
989fn io_error(err: std::io::Error) -> PipelineError {
990 PipelineError::Io(err.to_string())
991}
992
993fn serialization_error(err: impl std::fmt::Display) -> PipelineError {
994 PipelineError::Serialization(err.to_string())
995}
996
997fn fetch_client_error(err: figma_client::FetchClientError) -> PipelineError {
998 PipelineError::FetchClient(err.to_string())
999}
1000
1001fn normalizer_error(err: crate::figma_client::normalizer::NormalizationError) -> PipelineError {
1002 PipelineError::Normalizer(err.to_string())
1003}
1004
1005fn ui_spec_build_error(err: ui_spec::UiSpecBuildError) -> PipelineError {
1006 PipelineError::UiSpecBuild(err.to_string())
1007}
1008
1009#[cfg(test)]
1010mod tests;