Skip to main content

atman_runtime/tools/
preview.rs

1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use crate::error::RuntimeError;
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8#[derive(Debug, Clone)]
9pub struct PreviewConfig {
10    pub base_url: String,
11    pub timeout_ms: u64,
12    pub project_abs_path: String,
13    pub project_hint_slug: Option<String>,
14    pub max_body_bytes: usize,
15}
16
17impl Default for PreviewConfig {
18    fn default() -> Self {
19        Self {
20            base_url: "http://127.0.0.1:65097".into(),
21            timeout_ms: 3000,
22            project_abs_path: std::env::current_dir()
23                .map(|p| p.display().to_string())
24                .unwrap_or_default(),
25            project_hint_slug: None,
26            max_body_bytes: 1_000_000,
27        }
28    }
29}
30
31pub struct PreviewPush {
32    config: Arc<PreviewConfig>,
33    client: reqwest::Client,
34    project_id: Mutex<Option<String>>,
35    startup_lock: tokio::sync::Mutex<()>,
36}
37
38impl PreviewPush {
39    pub fn new(config: PreviewConfig) -> Self {
40        let client = reqwest::Client::builder()
41            .timeout(Duration::from_millis(config.timeout_ms))
42            .build()
43            .expect("build reqwest client");
44        Self {
45            config: Arc::new(config),
46            client,
47            project_id: Mutex::new(None),
48            startup_lock: tokio::sync::Mutex::new(()),
49        }
50    }
51
52    async fn ensure_project(&self) -> ResolveOutcome<String> {
53        if let Some(pid) = self.project_id.lock().unwrap().clone() {
54            return ResolveOutcome::Ok(pid);
55        }
56        let mut body = serde_json::Map::new();
57        body.insert(
58            "abs_path".into(),
59            serde_json::Value::String(self.config.project_abs_path.clone()),
60        );
61        if let Some(slug) = &self.config.project_hint_slug {
62            body.insert("hint_slug".into(), serde_json::Value::String(slug.clone()));
63        }
64        let url = format!("{}/api/projects", self.config.base_url);
65        match self.post_json(&url, &serde_json::Value::Object(body)).await {
66            ResolveOutcome::Ok(r) if r.status().is_success() => {
67                let json: serde_json::Value = match r.json().await {
68                    Ok(v) => v,
69                    Err(e) => return ResolveOutcome::Fail(format!("decode projects: {e}")),
70                };
71                let pid = json
72                    .get("id")
73                    .and_then(|v| v.as_str())
74                    .unwrap_or_default()
75                    .to_string();
76                if pid.is_empty() {
77                    return ResolveOutcome::Fail("register response missing id".into());
78                }
79                *self.project_id.lock().unwrap() = Some(pid.clone());
80                ResolveOutcome::Ok(pid)
81            }
82            ResolveOutcome::Ok(r) => ResolveOutcome::Fail(format!(
83                "register project http {}: {}",
84                r.status(),
85                r.text().await.unwrap_or_default()
86            )),
87            ResolveOutcome::Unavailable => ResolveOutcome::Unavailable,
88            ResolveOutcome::Fail(message) => {
89                ResolveOutcome::Fail(format!("register project {message}"))
90            }
91        }
92    }
93
94    async fn post_json(
95        &self,
96        url: &str,
97        body: &serde_json::Value,
98    ) -> ResolveOutcome<reqwest::Response> {
99        let mut response = self.client.post(url).json(body).send().await;
100        if response.as_ref().err().is_some_and(is_connection_refused) {
101            match self.ensure_local_server().await {
102                Ok(true) => response = self.client.post(url).json(body).send().await,
103                Ok(false) => {}
104                Err(message) => return ResolveOutcome::Fail(message),
105            }
106        }
107        match response {
108            Ok(response) => ResolveOutcome::Ok(response),
109            Err(error) if is_connection_refused(&error) => ResolveOutcome::Unavailable,
110            Err(error) => ResolveOutcome::Fail(format!("net: {error}")),
111        }
112    }
113
114    async fn ensure_local_server(&self) -> Result<bool, String> {
115        if self.config.base_url != "http://127.0.0.1:65097"
116            && self.config.base_url != "http://localhost:65097"
117        {
118            return Ok(false);
119        }
120        let _guard = self.startup_lock.lock().await;
121        if matches!(ping(&self.config.base_url, 300).await, PingResult::Ok) {
122            return Ok(true);
123        }
124        let executable = std::env::current_exe().map_err(|error| error.to_string())?;
125        let name = executable
126            .file_name()
127            .and_then(|name| name.to_str())
128            .unwrap_or("");
129        let mut command = std::process::Command::new(&executable);
130        match name {
131            "atman" => {
132                command.args(["preview", "serve", "--port", "65097"]);
133            }
134            "atman-daemon" => {
135                command.arg("--preview-serve");
136            }
137            _ => return Ok(false),
138        }
139        command
140            .stdin(std::process::Stdio::null())
141            .stdout(std::process::Stdio::null())
142            .stderr(std::process::Stdio::null());
143        #[cfg(unix)]
144        {
145            use std::os::unix::process::CommandExt;
146            command.process_group(0);
147        }
148        command
149            .spawn()
150            .map_err(|error| format!("start preview server: {error}"))?;
151        for _ in 0..20 {
152            tokio::time::sleep(Duration::from_millis(100)).await;
153            if matches!(ping(&self.config.base_url, 150).await, PingResult::Ok) {
154                return Ok(true);
155            }
156        }
157        Err("preview server did not become healthy after startup".into())
158    }
159
160    async fn ensure_topic(&self, pid: &str, topic_id: &str, title: &str) -> Result<(), String> {
161        let url = format!("{}/api/projects/{pid}/topics", self.config.base_url);
162        let body = serde_json::json!({
163            "id": topic_id,
164            "title": title,
165        });
166        let resp = match self.post_json(&url, &body).await {
167            ResolveOutcome::Ok(response) => response,
168            ResolveOutcome::Unavailable => return Err("topic server unavailable".into()),
169            ResolveOutcome::Fail(message) => return Err(format!("topic {message}")),
170        };
171        let status = resp.status();
172        if status.is_success() || status.as_u16() == 409 {
173            return Ok(());
174        }
175        Err(format!(
176            "topic http {status}: {}",
177            resp.text().await.unwrap_or_default()
178        ))
179    }
180}
181
182impl Tool for PreviewPush {
183    fn name(&self) -> &str {
184        "preview.push"
185    }
186
187    fn tier(&self) -> Tier {
188        Tier::One
189    }
190
191    fn description(&self) -> Option<&str> {
192        Some(
193            "Push markdown, mermaid, HTML, image, or diff content to the local preview server for browser review. Use it when the user asks to see a rendered artifact, diagram, diff, or audit page.",
194        )
195    }
196
197    fn input_schema(&self) -> serde_json::Value {
198        serde_json::json!({
199            "type": "object",
200            "properties": {
201                "topic": {"type": "string", "description": "Preview topic id to group related blocks."},
202                "title": {"type": "string", "description": "Human-readable topic title."},
203                "kind": {"type": "string", "enum": ["markdown", "mermaid", "html", "image", "diff"], "default": "markdown", "description": "Block type to render."},
204                "content": {"type": "string", "description": "Block content. Required for markdown, mermaid, and HTML; optional for image and diff when their specific fields are supplied."},
205                "image_base64": {"type": "string", "description": "Base64 image data for kind=image."},
206                "image_path": {"type": "string", "description": "Local image path for kind=image."},
207                "media_type": {"type": "string", "description": "Optional MIME type for image_base64, such as image/png."},
208                "raw_diff": {"type": "string", "description": "Raw patch text for kind=diff."},
209                "commit_sha": {"type": "string", "description": "Commit SHA for kind=diff commit mode. Requires repo_path."},
210                "repo_path": {"type": "string", "description": "Repository path for kind=diff commit mode. Requires commit_sha."}
211            },
212            "required": ["topic", "title"]
213        })
214    }
215
216    fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
217        Box::pin(async move {
218            let topic = extract_string(&args, "topic", 0)?;
219            let title = extract_string(&args, "title", 1)?;
220            let kind = extract_optional_string(&args, "kind").unwrap_or_else(|| "markdown".into());
221            let content = match kind.as_str() {
222                "image" | "diff" => extract_optional_string(&args, "content").unwrap_or_default(),
223                _ => extract_string(&args, "content", 2)?,
224            };
225
226            if content.len() > self.config.max_body_bytes {
227                return Err(RuntimeError::ToolFailed(format!(
228                    "preview.push: content {} bytes exceeds max {}",
229                    content.len(),
230                    self.config.max_body_bytes
231                )));
232            }
233
234            let pid = match self.ensure_project().await {
235                ResolveOutcome::Ok(id) => id,
236                ResolveOutcome::Unavailable => return Ok(unavailable()),
237                ResolveOutcome::Fail(msg) => {
238                    return Err(RuntimeError::ToolFailed(format!("preview.push: {msg}")));
239                }
240            };
241
242            self.ensure_topic(&pid, &topic, &title)
243                .await
244                .map_err(|e| RuntimeError::ToolFailed(format!("preview.push: {e}")))?;
245
246            let block = build_block(&kind, &content, &args, self.config.max_body_bytes)?;
247            let url = format!(
248                "{}/api/projects/{pid}/topics/{topic}/blocks",
249                self.config.base_url
250            );
251            let resp = self.post_json(&url, &block).await;
252            match resp {
253                ResolveOutcome::Ok(r) if r.status().is_success() => {
254                    let json: serde_json::Value = r.json().await.map_err(|e| {
255                        RuntimeError::ToolFailed(format!("preview.push decode: {e}"))
256                    })?;
257                    let block_id = json
258                        .get("id")
259                        .and_then(|v| v.as_str())
260                        .unwrap_or_default()
261                        .to_string();
262                    let preview_url = json
263                        .get("rendered_html_preview_url")
264                        .and_then(|v| v.as_str())
265                        .unwrap_or_default()
266                        .to_string();
267                    Ok(Value::Struct(vec![
268                        ("status".into(), Value::Str("ok".into())),
269                        ("project_id".into(), Value::Str(pid)),
270                        ("topic_id".into(), Value::Str(topic)),
271                        ("block_id".into(), Value::Str(block_id)),
272                        ("url".into(), Value::Str(preview_url)),
273                    ]))
274                }
275                ResolveOutcome::Ok(r) => Err(RuntimeError::ToolFailed(format!(
276                    "preview.push http {}: {}",
277                    r.status(),
278                    r.text().await.unwrap_or_default()
279                ))),
280                ResolveOutcome::Unavailable => Ok(unavailable()),
281                ResolveOutcome::Fail(message) => {
282                    Err(RuntimeError::ToolFailed(format!("preview.push {message}")))
283                }
284            }
285        })
286    }
287}
288
289pub async fn ping(base_url: &str, timeout_ms: u64) -> PingResult {
290    let client = match reqwest::Client::builder()
291        .timeout(Duration::from_millis(timeout_ms))
292        .build()
293    {
294        Ok(c) => c,
295        Err(e) => return PingResult::Fail(format!("build client: {e}")),
296    };
297    match client.get(format!("{base_url}/api/health")).send().await {
298        Ok(r) if r.status().is_success() => PingResult::Ok,
299        Ok(r) => PingResult::Fail(format!("http {}", r.status())),
300        Err(e) if is_connection_refused(&e) => PingResult::Unavailable,
301        Err(e) => PingResult::Fail(format!("net: {e}")),
302    }
303}
304
305#[derive(Debug)]
306pub enum PingResult {
307    Ok,
308    Unavailable,
309    Fail(String),
310}
311
312enum ResolveOutcome<T> {
313    Ok(T),
314    Unavailable,
315    Fail(String),
316}
317
318fn unavailable() -> Value {
319    Value::Struct(vec![
320        ("status".into(), Value::Str("unavailable".into())),
321        (
322            "hint".into(),
323            Value::Str("preview server not reachable on configured base_url".into()),
324        ),
325    ])
326}
327
328fn is_connection_refused(e: &reqwest::Error) -> bool {
329    let msg = format!("{e}");
330    msg.contains("Connection refused")
331        || msg.contains("connection refused")
332        || msg.contains("tcp connect error")
333        || e.is_connect()
334}
335
336fn build_block(
337    kind: &str,
338    content: &str,
339    args: &ToolArgs,
340    max_body_bytes: usize,
341) -> Result<serde_json::Value, RuntimeError> {
342    Ok(match kind {
343        "markdown" => serde_json::json!({ "kind": "markdown", "content": content }),
344        "mermaid" => serde_json::json!({ "kind": "mermaid", "source": content }),
345        "html" => serde_json::json!({ "kind": "html", "fragment": content }),
346        "image" => build_image_block(args, max_body_bytes)?,
347        "diff" => build_diff_block(content, args)?,
348        other => {
349            return Err(RuntimeError::ToolFailed(format!(
350                "preview.push: unsupported kind `{other}` (want markdown | mermaid | html | image | diff)"
351            )));
352        }
353    })
354}
355
356fn build_image_block(
357    args: &ToolArgs,
358    max_body_bytes: usize,
359) -> Result<serde_json::Value, RuntimeError> {
360    let base64 = extract_optional_string(args, "image_base64");
361    let path = extract_optional_string(args, "image_path");
362    let (b64, media_type) = match (base64, path) {
363        (Some(b), _) => (b, extract_optional_string(args, "media_type")),
364        (None, Some(p)) => {
365            let bytes = std::fs::read(&p).map_err(|e| {
366                RuntimeError::ToolFailed(format!("preview.push image_path {p}: {e}"))
367            })?;
368            if bytes.len() > max_body_bytes {
369                return Err(RuntimeError::ToolFailed(format!(
370                    "preview.push: image_path {} bytes exceeds max_body_bytes {max_body_bytes}; upload endpoint not implemented",
371                    bytes.len()
372                )));
373            }
374            use base64::Engine;
375            let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
376            let media = guess_media_type(&p);
377            (encoded, Some(media))
378        }
379        _ => {
380            return Err(RuntimeError::ToolFailed(
381                "preview.push image: expect one of image_base64 or image_path".into(),
382            ));
383        }
384    };
385    if b64.len() > max_body_bytes {
386        return Err(RuntimeError::ToolFailed(format!(
387            "preview.push image: base64 {} bytes exceeds max_body_bytes {max_body_bytes}",
388            b64.len()
389        )));
390    }
391    let mut block = serde_json::json!({ "kind": "image", "image_base64": b64 });
392    if let Some(m) = media_type
393        && let Some(obj) = block.as_object_mut()
394    {
395        obj.insert("media_type".to_string(), serde_json::Value::String(m));
396    }
397    Ok(block)
398}
399
400fn build_diff_block(content: &str, args: &ToolArgs) -> Result<serde_json::Value, RuntimeError> {
401    let raw_diff = extract_optional_string(args, "raw_diff");
402    let commit_sha = extract_optional_string(args, "commit_sha");
403    let repo_path = extract_optional_string(args, "repo_path");
404    if let (Some(sha), Some(repo)) = (commit_sha.as_ref(), repo_path.as_ref()) {
405        return Ok(serde_json::json!({
406            "kind": "diff",
407            "mode": "commit_diff",
408            "commit_sha": sha,
409            "repo_path": repo,
410        }));
411    }
412    let patch = raw_diff.or_else(|| {
413        if content.is_empty() {
414            None
415        } else {
416            Some(content.to_string())
417        }
418    });
419    let Some(patch) = patch else {
420        return Err(RuntimeError::ToolFailed(
421            "preview.push diff: expect either (commit_sha + repo_path) or raw_diff / content"
422                .into(),
423        ));
424    };
425    Ok(serde_json::json!({
426        "kind": "diff",
427        "mode": "raw_diff",
428        "patch_text": patch,
429    }))
430}
431
432fn guess_media_type(path: &str) -> String {
433    let lower = path.to_ascii_lowercase();
434    if lower.ends_with(".png") {
435        "image/png".into()
436    } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
437        "image/jpeg".into()
438    } else if lower.ends_with(".gif") {
439        "image/gif".into()
440    } else if lower.ends_with(".webp") {
441        "image/webp".into()
442    } else {
443        "application/octet-stream".into()
444    }
445}
446
447fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
448    let value = match args.named(name) {
449        Some(v) => v,
450        None => args.positional(pos)?,
451    };
452    match value {
453        Value::Str(s) => Ok(s.clone()),
454        Value::Path(p) => Ok(p.display().to_string()),
455        other => Err(RuntimeError::TypeMismatch {
456            expected: "string or path".into(),
457            actual: other.kind_name().into(),
458        }),
459    }
460}
461
462fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
463    match args.named(name)? {
464        Value::Str(s) => Some(s.clone()),
465        _ => None,
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use wiremock::matchers::{method, path};
473    use wiremock::{Mock, MockServer, ResponseTemplate};
474
475    fn cfg(base: String) -> PreviewConfig {
476        PreviewConfig {
477            base_url: base,
478            timeout_ms: 500,
479            project_abs_path: "/tmp/atman-test".into(),
480            project_hint_slug: Some("atman-test".into()),
481            max_body_bytes: 1_000_000,
482        }
483    }
484
485    #[tokio::test]
486    async fn push_markdown_block_registers_project_then_topic_then_block() {
487        let server = MockServer::start().await;
488        Mock::given(method("POST"))
489            .and(path("/api/projects"))
490            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
491                "id": "atman-test-abc",
492                "slug": "atman-test",
493                "id_source": "fallback_random",
494                "project_paths": [],
495                "agents_md_was_injected": false,
496                "url": "http://x",
497            })))
498            .expect(1)
499            .mount(&server)
500            .await;
501        Mock::given(method("POST"))
502            .and(path("/api/projects/atman-test-abc/topics"))
503            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
504                "id": "review-2026-07-03",
505                "url": "http://x",
506                "blocks_endpoint": "http://x",
507                "assets_endpoint": "http://x",
508            })))
509            .mount(&server)
510            .await;
511        Mock::given(method("POST"))
512            .and(path(
513                "/api/projects/atman-test-abc/topics/review-2026-07-03/blocks",
514            ))
515            .and(wiremock::matchers::body_partial_json(serde_json::json!({
516                "kind": "markdown",
517                "content": "# hello",
518            })))
519            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
520                "id": "blk_1",
521                "position": 0,
522                "rendered_html_preview_url": "http://x/blk1",
523            })))
524            .expect(1)
525            .mount(&server)
526            .await;
527
528        let tool = PreviewPush::new(cfg(server.uri()));
529        let ctx = ToolCtx::new();
530        let args = ToolArgs {
531            positional: vec![
532                Value::Str("review-2026-07-03".into()),
533                Value::Str("Review".into()),
534                Value::Str("# hello".into()),
535            ],
536            named: vec![],
537        };
538        let v = tool.call(args, &ctx).await.unwrap();
539        let Value::Struct(fields) = v else {
540            panic!("expected struct");
541        };
542        assert!(matches!(
543            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
544            Value::Str(s) if s == "ok"
545        ));
546    }
547
548    #[tokio::test]
549    async fn push_returns_unavailable_on_connection_refused() {
550        let tool = PreviewPush::new(cfg("http://127.0.0.1:1".into()));
551        let ctx = ToolCtx::new();
552        let args = ToolArgs {
553            positional: vec![
554                Value::Str("t".into()),
555                Value::Str("T".into()),
556                Value::Str("c".into()),
557            ],
558            named: vec![],
559        };
560        let v = tool.call(args, &ctx).await.unwrap();
561        let Value::Struct(fields) = v else {
562            panic!("expected struct");
563        };
564        assert!(matches!(
565            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
566            Value::Str(s) if s == "unavailable"
567        ));
568    }
569
570    #[tokio::test]
571    async fn push_treats_409_on_topic_as_idempotent_success() {
572        let server = MockServer::start().await;
573        Mock::given(method("POST"))
574            .and(path("/api/projects"))
575            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
576                "id": "p1",
577                "slug": "p",
578                "id_source": "fallback_random",
579                "project_paths": [],
580                "agents_md_was_injected": false,
581                "url": "http://x",
582            })))
583            .mount(&server)
584            .await;
585        Mock::given(method("POST"))
586            .and(path("/api/projects/p1/topics"))
587            .respond_with(ResponseTemplate::new(409).set_body_string("duplicate"))
588            .mount(&server)
589            .await;
590        Mock::given(method("POST"))
591            .and(path("/api/projects/p1/topics/t/blocks"))
592            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
593                "id": "b1",
594                "position": 1,
595                "rendered_html_preview_url": "http://x",
596            })))
597            .expect(1)
598            .mount(&server)
599            .await;
600
601        let tool = PreviewPush::new(cfg(server.uri()));
602        let ctx = ToolCtx::new();
603        let args = ToolArgs {
604            positional: vec![
605                Value::Str("t".into()),
606                Value::Str("T".into()),
607                Value::Str("c".into()),
608            ],
609            named: vec![],
610        };
611        let v = tool.call(args, &ctx).await.unwrap();
612        let Value::Struct(fields) = v else {
613            panic!("expected struct");
614        };
615        assert!(matches!(
616            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
617            Value::Str(s) if s == "ok"
618        ));
619    }
620
621    #[tokio::test]
622    async fn push_rejects_unknown_block_kind() {
623        let server = MockServer::start().await;
624        Mock::given(method("POST"))
625            .and(path("/api/projects"))
626            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
627                "id": "p",
628                "slug": "p",
629                "id_source": "fallback_random",
630                "project_paths": [],
631                "agents_md_was_injected": false,
632                "url": "http://x",
633            })))
634            .mount(&server)
635            .await;
636        Mock::given(method("POST"))
637            .and(path("/api/projects/p/topics"))
638            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
639                "id": "t",
640                "url": "",
641                "blocks_endpoint": "",
642                "assets_endpoint": ""
643            })))
644            .mount(&server)
645            .await;
646        let tool = PreviewPush::new(cfg(server.uri()));
647        let ctx = ToolCtx::new();
648        let args = ToolArgs {
649            positional: vec![
650                Value::Str("t".into()),
651                Value::Str("T".into()),
652                Value::Str("c".into()),
653            ],
654            named: vec![("kind".into(), Value::Str("video".into()))],
655        };
656        let err = tool.call(args, &ctx).await.unwrap_err();
657        assert!(format!("{err}").contains("unsupported kind"));
658    }
659
660    async fn mount_project_and_topic(server: &MockServer) {
661        Mock::given(method("POST"))
662            .and(path("/api/projects"))
663            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
664                "id": "p",
665                "slug": "p",
666                "id_source": "fallback_random",
667                "project_paths": [],
668                "agents_md_was_injected": false,
669                "url": "http://x",
670            })))
671            .mount(server)
672            .await;
673        Mock::given(method("POST"))
674            .and(path("/api/projects/p/topics"))
675            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
676                "id": "t",
677                "url": "",
678                "blocks_endpoint": "",
679                "assets_endpoint": ""
680            })))
681            .mount(server)
682            .await;
683    }
684
685    #[tokio::test]
686    async fn push_image_base64_lands_as_image_block_with_media_type() {
687        let server = MockServer::start().await;
688        mount_project_and_topic(&server).await;
689        Mock::given(method("POST"))
690            .and(path("/api/projects/p/topics/t/blocks"))
691            .and(wiremock::matchers::body_partial_json(serde_json::json!({
692                "kind": "image",
693                "image_base64": "iVBOR",
694                "media_type": "image/png",
695            })))
696            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
697                "id": "b_img",
698                "position": 0,
699                "rendered_html_preview_url": "http://x/b_img",
700            })))
701            .expect(1)
702            .mount(&server)
703            .await;
704
705        let tool = PreviewPush::new(cfg(server.uri()));
706        let ctx = ToolCtx::new();
707        let args = ToolArgs {
708            positional: vec![Value::Str("t".into()), Value::Str("T".into())],
709            named: vec![
710                ("kind".into(), Value::Str("image".into())),
711                ("image_base64".into(), Value::Str("iVBOR".into())),
712                ("media_type".into(), Value::Str("image/png".into())),
713            ],
714        };
715        let v = tool.call(args, &ctx).await.unwrap();
716        let Value::Struct(fields) = v else {
717            panic!("expected struct");
718        };
719        assert!(matches!(
720            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
721            Value::Str(s) if s == "ok"
722        ));
723    }
724
725    #[tokio::test]
726    async fn push_image_path_reads_bytes_and_encodes_base64() {
727        let server = MockServer::start().await;
728        mount_project_and_topic(&server).await;
729        let tmp = tempfile::NamedTempFile::with_suffix(".png").unwrap();
730        std::fs::write(tmp.path(), b"fake-png-bytes").unwrap();
731        Mock::given(method("POST"))
732            .and(path("/api/projects/p/topics/t/blocks"))
733            .and(wiremock::matchers::body_partial_json(serde_json::json!({
734                "kind": "image",
735                "media_type": "image/png",
736            })))
737            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
738                "id": "b_img_path",
739                "position": 0,
740                "rendered_html_preview_url": "http://x",
741            })))
742            .expect(1)
743            .mount(&server)
744            .await;
745
746        let tool = PreviewPush::new(cfg(server.uri()));
747        let ctx = ToolCtx::new();
748        let args = ToolArgs {
749            positional: vec![Value::Str("t".into()), Value::Str("T".into())],
750            named: vec![
751                ("kind".into(), Value::Str("image".into())),
752                (
753                    "image_path".into(),
754                    Value::Str(tmp.path().display().to_string()),
755                ),
756            ],
757        };
758        let v = tool.call(args, &ctx).await.unwrap();
759        let Value::Struct(fields) = v else {
760            panic!("expected struct");
761        };
762        assert!(matches!(
763            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
764            Value::Str(s) if s == "ok"
765        ));
766    }
767
768    #[tokio::test]
769    async fn push_diff_raw_diff_carries_patch_text() {
770        let server = MockServer::start().await;
771        mount_project_and_topic(&server).await;
772        let patch = "--- a/foo\n+++ b/foo\n@@ -1 +1 @@\n-old\n+new\n";
773        Mock::given(method("POST"))
774            .and(path("/api/projects/p/topics/t/blocks"))
775            .and(wiremock::matchers::body_partial_json(serde_json::json!({
776                "kind": "diff",
777                "mode": "raw_diff",
778                "patch_text": patch,
779            })))
780            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
781                "id": "b_diff",
782                "position": 0,
783                "rendered_html_preview_url": "http://x/diff",
784            })))
785            .expect(1)
786            .mount(&server)
787            .await;
788
789        let tool = PreviewPush::new(cfg(server.uri()));
790        let ctx = ToolCtx::new();
791        let args = ToolArgs {
792            positional: vec![Value::Str("t".into()), Value::Str("T".into())],
793            named: vec![
794                ("kind".into(), Value::Str("diff".into())),
795                ("raw_diff".into(), Value::Str(patch.into())),
796            ],
797        };
798        let v = tool.call(args, &ctx).await.unwrap();
799        let Value::Struct(fields) = v else {
800            panic!("expected struct");
801        };
802        assert!(matches!(
803            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
804            Value::Str(s) if s == "ok"
805        ));
806    }
807
808    #[tokio::test]
809    async fn push_diff_commit_sha_switches_mode() {
810        let server = MockServer::start().await;
811        mount_project_and_topic(&server).await;
812        Mock::given(method("POST"))
813            .and(path("/api/projects/p/topics/t/blocks"))
814            .and(wiremock::matchers::body_partial_json(serde_json::json!({
815                "kind": "diff",
816                "mode": "commit_diff",
817                "commit_sha": "abc123",
818                "repo_path": "/repo",
819            })))
820            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
821                "id": "b_diff_sha",
822                "position": 0,
823                "rendered_html_preview_url": "http://x/diff_sha",
824            })))
825            .expect(1)
826            .mount(&server)
827            .await;
828
829        let tool = PreviewPush::new(cfg(server.uri()));
830        let ctx = ToolCtx::new();
831        let args = ToolArgs {
832            positional: vec![Value::Str("t".into()), Value::Str("T".into())],
833            named: vec![
834                ("kind".into(), Value::Str("diff".into())),
835                ("commit_sha".into(), Value::Str("abc123".into())),
836                ("repo_path".into(), Value::Str("/repo".into())),
837            ],
838        };
839        let v = tool.call(args, &ctx).await.unwrap();
840        let Value::Struct(fields) = v else {
841            panic!("expected struct");
842        };
843        assert!(matches!(
844            &fields.iter().find(|(k, _)| k == "status").unwrap().1,
845            Value::Str(s) if s == "ok"
846        ));
847    }
848
849    #[tokio::test]
850    async fn push_image_without_data_returns_error() {
851        let tool = PreviewPush::new(cfg("http://127.0.0.1:1".into()));
852        let ctx = ToolCtx::new();
853        let args = ToolArgs {
854            positional: vec![Value::Str("t".into()), Value::Str("T".into())],
855            named: vec![("kind".into(), Value::Str("image".into()))],
856        };
857        let err = tool.call(args, &ctx).await;
858        match err {
859            Err(RuntimeError::ToolFailed(msg)) => {
860                assert!(
861                    msg.contains("image_base64") || msg.contains("image_path"),
862                    "err: {msg}"
863                );
864            }
865            Ok(Value::Struct(fields))
866                if matches!(
867                    &fields.iter().find(|(k, _)| k == "status").unwrap().1,
868                    Value::Str(s) if s == "unavailable"
869                ) => {}
870            other => panic!("expected image data error, got {other:?}"),
871        }
872    }
873
874    #[tokio::test]
875    async fn push_rejects_content_over_max_bytes() {
876        let mut c = cfg("http://127.0.0.1:1".into());
877        c.max_body_bytes = 10;
878        let tool = PreviewPush::new(c);
879        let ctx = ToolCtx::new();
880        let args = ToolArgs {
881            positional: vec![
882                Value::Str("t".into()),
883                Value::Str("T".into()),
884                Value::Str("x".repeat(100)),
885            ],
886            named: vec![],
887        };
888        let err = tool.call(args, &ctx).await.unwrap_err();
889        assert!(format!("{err}").contains("exceeds max"));
890    }
891
892    #[tokio::test]
893    async fn ping_returns_ok_on_healthy_server() {
894        let server = MockServer::start().await;
895        Mock::given(method("GET"))
896            .and(path("/api/health"))
897            .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
898            .mount(&server)
899            .await;
900        assert!(matches!(ping(&server.uri(), 500).await, PingResult::Ok));
901    }
902
903    #[tokio::test]
904    async fn ping_returns_unavailable_on_connection_refused() {
905        assert!(matches!(
906            ping("http://127.0.0.1:1", 200).await,
907            PingResult::Unavailable
908        ));
909    }
910}