Skip to main content

specloom_core/
lib.rs

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;