use crate::publish::article::Article;
use crate::publish::config::DevToConfig;
use crate::publish::cover_image::{self, CoverImage};
use crate::publish::target::{HttpTarget, PublishOutcome};
use anyhow::{Result, bail};
use serde::Serialize;
pub struct DevTo {
api_key: String,
http: HttpTarget,
}
impl DevTo {
pub fn new(client: reqwest::Client, cfg: &DevToConfig) -> Self {
Self::with_base_url(client, cfg, "https://dev.to")
}
fn with_base_url(
client: reqwest::Client,
cfg: &DevToConfig,
base_url: impl Into<String>,
) -> Self {
Self {
api_key: cfg.api_key.clone(),
http: HttpTarget::new(client, base_url),
}
}
}
#[derive(Serialize)]
struct ArticleBody<'a> {
article: ArticleFields<'a>,
}
#[derive(Serialize)]
struct ArticleFields<'a> {
title: &'a str,
body_markdown: &'a str,
published: bool,
description: &'a str,
tags: String,
#[serde(skip_serializing_if = "Option::is_none")]
main_image: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
canonical_url: Option<&'a str>,
}
impl DevTo {
pub async fn publish(&self, article: &Article, update: bool) -> Result<PublishOutcome> {
if update {
bail!("the devto target doesn't support --update yet");
}
let main_image = match cover_image::resolve(article)? {
Some(CoverImage::Url(url)) => Some(url),
Some(CoverImage::Local(_)) => {
return Err(cover_image::local_path_not_supported_error("devto"));
}
None => None,
};
let body = ArticleBody {
article: ArticleFields {
title: &article.title,
body_markdown: &article.content,
published: true,
description: &article.summary,
tags: article
.tags
.iter()
.take(4)
.cloned()
.collect::<Vec<_>>()
.join(","),
main_image: main_image.as_deref(),
canonical_url: article.canonical_url.as_deref(),
},
};
let resp = self
.http
.client
.post(format!("{}/api/articles", self.http.base_url))
.header("api-key", &self.api_key)
.json(&body)
.send()
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
bail!("dev.to returned {status}: {text}");
}
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
let url = parsed
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("(published, no URL in response)")
.to_string();
Ok(PublishOutcome { message: url })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::publish::article::parse_article;
use assert_fs::{TempDir, prelude::*};
use httpmock::prelude::*;
fn sample_article(dir: &TempDir) -> Article {
let file = dir.child("post.md");
file.write_str(
"---\n\
title: \"Hello\"\n\
date: 2026-07-30\n\
summary: \"A test\"\n\
tags: [rust, cli, testing, extra, dropped]\n\
publish: [devto]\n\
---\n\
Body.\n",
)
.unwrap();
parse_article(file.path(), None).unwrap()
}
#[tokio::test]
async fn publish_posts_expected_body_and_parses_url() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let article = sample_article(&dir);
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api/articles")
.header("api-key", "secret")
.json_body_partial(
r#"{"article":{"title":"Hello","tags":"rust,cli,testing,extra"}}"#,
);
then.status(201)
.json_body(serde_json::json!({"url": "https://dev.to/x/hello"}));
});
let target = DevTo::with_base_url(
reqwest::Client::new(),
&DevToConfig {
api_key: "secret".into(),
},
server.base_url(),
);
let outcome = target.publish(&article, false).await.unwrap();
mock.assert();
assert_eq!(outcome.message, "https://dev.to/x/hello");
}
#[tokio::test]
async fn non_success_status_is_an_error() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let article = sample_article(&dir);
server.mock(|when, then| {
when.method(POST).path("/api/articles");
then.status(422).body(r#"{"error":"invalid"}"#);
});
let target = DevTo::with_base_url(
reqwest::Client::new(),
&DevToConfig {
api_key: "secret".into(),
},
server.base_url(),
);
let err = target.publish(&article, false).await.unwrap_err();
assert!(err.to_string().contains("422"));
}
#[tokio::test]
async fn update_is_not_supported() {
let dir = TempDir::new().unwrap();
let article = sample_article(&dir);
let target = DevTo::with_base_url(
reqwest::Client::new(),
&DevToConfig {
api_key: "x".into(),
},
"http://unused",
);
let err = target.publish(&article, true).await.unwrap_err();
assert!(err.to_string().contains("doesn't support --update"));
}
#[tokio::test]
async fn cover_image_url_is_sent_as_main_image() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let file = dir.child("post.md");
file.write_str(
"---\ntitle: \"Hello\"\ndate: 2026-07-30\nsummary: \"s\"\n\
cover_image: https://example.com/hero.jpg\npublish: [devto]\n---\nBody.\n",
)
.unwrap();
let article = parse_article(file.path(), None).unwrap();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api/articles")
.json_body_partial(r#"{"article":{"main_image":"https://example.com/hero.jpg"}}"#);
then.status(201)
.json_body(serde_json::json!({"url": "https://dev.to/x/hello"}));
});
let target = DevTo::with_base_url(
reqwest::Client::new(),
&DevToConfig {
api_key: "x".into(),
},
server.base_url(),
);
target.publish(&article, false).await.unwrap();
mock.assert();
}
#[tokio::test]
async fn cover_image_local_path_is_a_clear_error() {
let dir = TempDir::new().unwrap();
dir.child("hero.jpg").write_binary(&[0xff, 0xd8]).unwrap();
let file = dir.child("post.md");
file.write_str(
"---\ntitle: \"Hello\"\ndate: 2026-07-30\nsummary: \"s\"\n\
cover_image: hero.jpg\npublish: [devto]\n---\nBody.\n",
)
.unwrap();
let article = parse_article(file.path(), None).unwrap();
let target = DevTo::with_base_url(
reqwest::Client::new(),
&DevToConfig {
api_key: "x".into(),
},
"http://unused",
);
let err = target.publish(&article, false).await.unwrap_err();
assert!(
err.to_string()
.contains("only accepts an already-hosted URL")
);
}
}