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