Skip to main content

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}