rss_cli/model.rs
1//! Serialized output types — **the AI-facing API contract**.
2//!
3//! Field names here are a stable contract: agents depend on them. Optional fields are
4//! serialized as `null` (never omitted) so the shape is predictable across every item.
5//! The authoritative schema is produced from these structs via [`crate::output::schema_for`]
6//! (`schemars`); the docs in the plan are only illustrative.
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11/// Output schema version. Bump on any breaking change to these structs.
12pub const SCHEMA_VERSION: &str = "1";
13
14/// Top-level result of `rss fetch`.
15#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
16pub struct FetchOutput {
17 pub schema_version: String,
18 /// RFC-3339 / ISO-8601 UTC timestamp of when this invocation ran.
19 pub fetched_at: String,
20 /// Total number of items returned across every feed (after `limit`/`--since`). Lets an
21 /// agent budget before walking the `feeds` array.
22 pub total_items: usize,
23 /// Sum of every item's `content_tokens_est` (reflects truncation). The number to budget
24 /// a response against.
25 pub total_content_tokens_est: u64,
26 pub feeds: Vec<FeedResult>,
27 /// Feed-level errors mirrored here for quick scanning (also present per-feed).
28 pub errors: Vec<ErrorObj>,
29 /// Non-fatal data-quality warnings (e.g. a content converter fell back to a tag strip,
30 /// or a feed's items are entirely undated). Empty `[]` normally; each carries its
31 /// `feed_url`. Distinct from `errors`, which mean a feed failed outright.
32 pub warnings: Vec<Warning>,
33 /// Present (non-`null`) when this result was bounded — an item cap was applied, item
34 /// bodies were truncated, or items were omitted to fit a size budget. `null` otherwise.
35 /// Primarily populated by the MCP server, which bounds responses (see the `rss mcp`
36 /// docs); the CLI populates it only when `--max-content-chars` truncates content.
37 pub truncation: Option<TruncationInfo>,
38}
39
40impl FetchOutput {
41 pub fn new(fetched_at: String) -> Self {
42 Self {
43 schema_version: SCHEMA_VERSION.to_string(),
44 fetched_at,
45 total_items: 0,
46 total_content_tokens_est: 0,
47 feeds: Vec::new(),
48 errors: Vec::new(),
49 warnings: Vec::new(),
50 truncation: None,
51 }
52 }
53}
54
55/// A non-fatal data-quality note about a feed (the feed still parsed and produced items).
56/// Surfaces silent fallbacks an agent should know about — e.g. lower-fidelity content
57/// extraction, or items it cannot order by time.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct Warning {
60 /// The feed this warning pertains to, if applicable.
61 pub feed_url: Option<String>,
62 /// Stable, machine-readable code (e.g. `CONTENT_EXTRACTION_FALLBACK`, `UNDATED_ITEMS`).
63 pub code: String,
64 pub message: String,
65}
66
67/// Describes how a [`FetchOutput`] was bounded. A summary so an agent can tell at a glance
68/// that it is not seeing the full, untruncated result and how to adjust.
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
70pub struct TruncationInfo {
71 /// The item cap actually applied (e.g. the MCP default of 25), or `null` if none.
72 pub applied_limit: Option<usize>,
73 /// Number of items whose `content` was truncated (e.g. by `max_content_chars`).
74 pub items_content_truncated: usize,
75 /// Number of items dropped entirely to fit a response budget. `0` unless the server
76 /// shed items (a Tier-2 behavior; always `0` in the cap-and-error path).
77 pub items_omitted: usize,
78 /// Rough token estimate of the (possibly reduced) serialized response, if computed.
79 pub estimated_tokens: Option<usize>,
80 /// Human/agent-facing hint on how to adjust the request (e.g. which knob to pass).
81 pub suggestion: Option<String>,
82}
83
84/// Outcome of fetching a single feed.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "snake_case")]
87pub enum FeedStatus {
88 /// Fetched and parsed fresh content.
89 Ok,
90 /// Server returned `304 Not Modified`; served from cache.
91 NotModified,
92 /// The feed failed to fetch or parse; see `error`.
93 Error,
94}
95
96/// Per-feed result.
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98pub struct FeedResult {
99 pub feed_url: String,
100 pub status: FeedStatus,
101 pub from_cache: bool,
102 pub title: Option<String>,
103 pub site_url: Option<String>,
104 /// Feed-level last-updated timestamp (RFC-3339 UTC), if the feed provides one.
105 pub updated: Option<String>,
106 /// Number of items returned for this feed (equals `items.len()`; surfaced as an explicit
107 /// budgeting count).
108 pub item_count: usize,
109 /// Sum of this feed's items' `content_tokens_est` (reflects truncation).
110 pub content_tokens_est_total: u64,
111 pub items: Vec<Item>,
112 pub error: Option<ErrorObj>,
113}
114
115impl FeedResult {
116 /// Construct an error result for a feed that failed before producing items.
117 pub fn error(feed_url: impl Into<String>, error: ErrorObj) -> Self {
118 Self {
119 feed_url: feed_url.into(),
120 status: FeedStatus::Error,
121 from_cache: false,
122 title: None,
123 site_url: None,
124 updated: None,
125 item_count: 0,
126 content_tokens_est_total: 0,
127 items: Vec::new(),
128 error: Some(error),
129 }
130 }
131}
132
133/// Which feed field the stable [`Item::id`] was derived from.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "snake_case")]
136pub enum IdSource {
137 Link,
138 Guid,
139 Hash,
140}
141
142/// The format of [`Item::content`].
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
144#[serde(rename_all = "snake_case")]
145pub enum ContentFormat {
146 #[default]
147 Markdown,
148 Text,
149 Html,
150 /// Content extraction disabled (`content` will be `null`).
151 None,
152}
153
154/// A single feed item / entry.
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
156pub struct Item {
157 /// Stable, deterministic identifier (see [`crate::identity`]). Stable across runs.
158 pub id: String,
159 pub id_source: IdSource,
160 pub feed_url: String,
161 pub title: Option<String>,
162 /// Resolved, absolute permalink for the item.
163 pub url: Option<String>,
164 pub authors: Vec<String>,
165 /// RFC-3339 UTC publication timestamp. **May be `null` even for a normal item** — some
166 /// feeds (e.g. Reddit comment `.rss`) populate only `updated`. A consumer that
167 /// time-filters items should fall back to `updated` when this is `null`; rss-cli's own
168 /// `--since` and newest-first ordering already key on `published` then `updated`.
169 pub published: Option<String>,
170 /// RFC-3339 UTC last-updated timestamp. The reliable timestamp for feeds that omit
171 /// `published` (see `published`).
172 pub updated: Option<String>,
173 pub summary: Option<String>,
174 /// Item body in the requested `content_format` (or `null` when `--content none`).
175 pub content: Option<String>,
176 pub content_format: ContentFormat,
177 /// Rough token estimate for `content` (for agent budgeting). Reflects the truncated
178 /// content when `content_truncated` is `true`.
179 pub content_tokens_est: u32,
180 /// `true` when `content` was cut short (e.g. by `max_content_chars` or a response
181 /// budget). The body ends with an ellipsis marker; fetch the item via `get_item` /
182 /// `rss show` without a cap for the full text.
183 pub content_truncated: bool,
184 /// 16-hex SHA-256 of the *full, pre-truncation* extracted content in the requested
185 /// `content_format`. Stable across runs, so an agent can detect when an item's body
186 /// changed without diffing text. `null` when `content` is `null` (`--content none`).
187 pub content_hash: Option<String>,
188 pub categories: Vec<String>,
189 pub enclosures: Vec<Enclosure>,
190 /// The raw feed-provided guid/id, for reference (not necessarily stable).
191 pub guid: Option<String>,
192}
193
194/// A media attachment (podcast audio, image, etc.).
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
196pub struct Enclosure {
197 pub url: String,
198 pub mime: Option<String>,
199 pub length: Option<u64>,
200}
201
202/// A structured, machine-readable error. Emitted to stdout under `--format json` and
203/// always carried in [`FeedResult::error`] / [`FetchOutput::errors`].
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
205pub struct ErrorObj {
206 /// The feed this error pertains to, if applicable.
207 pub feed_url: Option<String>,
208 /// Stable error code enum value (e.g. `FEED_FETCH_FAILED`). See [`crate::error`].
209 pub code: String,
210 pub message: String,
211 /// Free-form extra context (HTTP status, etc.). `{}` when empty.
212 #[serde(default)]
213 pub details: serde_json::Value,
214}
215
216impl ErrorObj {
217 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
218 Self {
219 feed_url: None,
220 code: code.into(),
221 message: message.into(),
222 details: serde_json::Value::Object(Default::default()),
223 }
224 }
225
226 pub fn with_feed(mut self, feed_url: impl Into<String>) -> Self {
227 self.feed_url = Some(feed_url.into());
228 self
229 }
230
231 pub fn with_details(mut self, details: serde_json::Value) -> Self {
232 self.details = details;
233 self
234 }
235}
236
237/// Top-level result of `rss discover`.
238#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
239pub struct DiscoverOutput {
240 pub schema_version: String,
241 pub site_url: String,
242 pub feeds: Vec<DiscoveredFeed>,
243}
244
245impl DiscoverOutput {
246 pub fn new(site_url: impl Into<String>, feeds: Vec<DiscoveredFeed>) -> Self {
247 Self {
248 schema_version: SCHEMA_VERSION.to_string(),
249 site_url: site_url.into(),
250 feeds,
251 }
252 }
253}
254
255/// A feed discovered on a website's homepage.
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
257pub struct DiscoveredFeed {
258 pub url: String,
259 /// `"rss" | "atom" | "json" | "unknown"`.
260 pub feed_type: String,
261 pub title: Option<String>,
262}