Skip to main content

intelli_shell/process/
changelog.rs

1use color_eyre::Result;
2use crossterm::style::{Attribute, Stylize};
3use tokio_util::sync::CancellationToken;
4use tracing::instrument;
5
6use crate::{
7    cli::ChangelogProcess,
8    config::Config,
9    errors::AppError,
10    format_error,
11    process::{Process, ProcessOutput},
12    service::{CURRENT_VERSION, IntelliShellService},
13    utils::{VersionExt, render_markdown_to_ansi},
14};
15
16impl Process for ChangelogProcess {
17    #[instrument(skip_all)]
18    async fn execute(
19        self,
20        config: Config,
21        service: IntelliShellService,
22        cancellation_token: CancellationToken,
23    ) -> Result<ProcessOutput> {
24        let from_tag = self.from.to_tag();
25
26        // Input validation
27        if let Some(ref to) = self.to
28            && &self.from > to
29        {
30            return Ok(ProcessOutput::fail().stderr(format_error!(
31                config.theme,
32                "Invalid criteria: from ({}) > to ({})",
33                from_tag.cyan(),
34                to.to_tag().cyan()
35            )));
36        }
37
38        // Retrieve stored releases
39        let all_releases = match service.get_or_fetch_releases(false, cancellation_token).await {
40            Ok(r) => r,
41            Err(AppError::UserFacing(err)) => {
42                return Ok(ProcessOutput::fail().stderr(format_error!(config.theme, "{err}")));
43            }
44            Err(AppError::Unexpected(report)) => return Err(report),
45        };
46
47        // Check 'to' version existence
48        if let Some(ref to) = self.to
49            && !all_releases.iter().any(|r| &r.version >= to)
50        {
51            return Ok(ProcessOutput::fail().stderr(format_error!(
52                config.theme,
53                "It looks like {} hasn't been released yet! \nYou can omit the '--to' flag to see all available \
54                 releases up to the latest.",
55                to.to_tag().red()
56            )));
57        }
58
59        // Filter from / to
60        let filtered_releases = all_releases
61            .iter()
62            .filter(|r| {
63                if r.version < self.from {
64                    return false;
65                }
66                if let Some(ref t) = self.to
67                    && &r.version > t
68                {
69                    return false;
70                }
71                true
72            })
73            .collect::<Vec<_>>();
74
75        // Check if any releases were found between the range
76        if filtered_releases.is_empty() {
77            return Ok(
78                ProcessOutput::fail().stderr(format_error!(config.theme, "No releases found matching the criteria"))
79            );
80        }
81
82        // Filter major / minor
83        let filtered_releases = filtered_releases
84            .into_iter()
85            .filter(|r| {
86                if r.version < self.from {
87                    return false;
88                }
89                if let Some(ref t) = self.to
90                    && &r.version > t
91                {
92                    return false;
93                }
94                if self.major && (r.version.minor != 0 || r.version.patch != 0) {
95                    return false;
96                }
97                if self.minor && r.version.patch != 0 {
98                    return false;
99                }
100                true
101            })
102            .collect::<Vec<_>>();
103
104        // Check if any releases were found after major/minor filtering
105        if filtered_releases.is_empty() {
106            let filter_type = match (self.major, self.minor) {
107                (true, _) => "major",
108                (_, true) => "minor",
109                _ => "relevant",
110            };
111
112            let msg = match self.to {
113                Some(to_ver) => format!(
114                    "⚠️ No {} releases found between {} and {}",
115                    filter_type,
116                    from_tag.cyan(),
117                    to_ver.to_tag().cyan()
118                ),
119                None => format!("⚠️ No {} releases found after {}", filter_type, from_tag.cyan()),
120            };
121
122            return Ok(ProcessOutput::success().stderr(msg));
123        }
124
125        // Warn on stale 'from' version
126        if let Some(oldest_release) = all_releases.last()
127            && self.from < oldest_release.version
128        {
129            eprintln!(
130                "⚠️  {} {}",
131                config.theme.error.apply(from_tag.red()),
132                config
133                    .theme
134                    .primary
135                    .apply("is too old, please check GitHub Releases to view full changelog.")
136            );
137        } else if !all_releases.iter().any(|r| r.version == self.from) {
138            eprintln!(
139                "⚠️  {} {}",
140                config.theme.error.apply(from_tag.red()),
141                config
142                    .theme
143                    .primary
144                    .apply("doesn't exist, but here's the changelog for newer releases.")
145            );
146        }
147
148        // Prepare changelog and return it
149        let changelog = filtered_releases.iter().rev().fold(String::new(), |mut acc, r| {
150            let mut title = r.title.as_str();
151            let mut body = r.body.as_deref().unwrap_or("").trim_end();
152
153            // If it starts with a level 2 header consider it the release title
154            if r.title == r.tag && body.starts_with("## ") {
155                if let Some((first_line, rest)) = body.split_once('\n') {
156                    title = first_line.trim_start_matches("## ").trim();
157                    body = rest.trim();
158                } else {
159                    title = body.trim_start_matches("## ").trim();
160                    body = "";
161                }
162            }
163
164            let is_current = r.version == *CURRENT_VERSION;
165            let is_current_mark = if is_current { " (current)" } else { "" };
166            let header = if title != r.tag {
167                format!("{}{} - {}", r.tag, is_current_mark, title)
168            } else {
169                format!("{}{}", r.tag, is_current_mark)
170            };
171
172            let line_width = 60usize;
173            let line_len = line_width.saturating_sub(header.len() + 4);
174            let line = "─".repeat(line_len);
175
176            let mut style = if is_current {
177                config.theme.highlight_accent_full()
178            } else {
179                config.theme.highlight_primary_full()
180            };
181            style.attributes.set(Attribute::Bold);
182
183            acc.push_str(&style.apply(&format!("── {header} {line}")).to_string());
184            acc.push_str("\n\n");
185
186            if !body.is_empty() {
187                acc.push_str(&render_markdown_to_ansi(body, &config.theme));
188                acc.push_str("\n\n");
189            }
190            acc
191        });
192
193        Ok(ProcessOutput::success().stdout(format!("\n{}\n", changelog.trim_end())))
194    }
195}