1use clap::Subcommand;
2
3use crate::api::HtbClient;
4use crate::output::{self, OutputFormat};
5
6#[derive(Subcommand)]
7#[command(
8 after_help = "Examples:\n htb challenges list --category Web Web challenges\n htb challenges categories All categories\n htb challenges info Poly Challenge details\n htb challenges start Poly Start container instance\n htb challenges submit 112 'HTB{f}' Submit a flag\n htb challenges download Poly Download challenge files"
9)]
10pub enum ChallengeCommand {
11 List {
13 #[arg(long, help = "Filter by category")]
14 category: Option<String>,
15 #[arg(long, help = "Filter by state (active, retired_free)")]
16 state: Option<String>,
17 #[arg(long, help = "Search by keyword (server-side)")]
18 keyword: Option<String>,
19 #[arg(long, help = "Page number")]
20 page: Option<u32>,
21 },
22 Categories,
24 Info {
26 slug: String,
28 },
29 Download {
31 slug: String,
33 },
34 Start {
36 slug: String,
38 },
39 Stop {
41 slug: String,
43 },
44 Writeup {
46 slug: String,
48 },
49 Submit {
51 id: u64,
53 flag: String,
55 },
56}
57
58pub async fn handle(
59 client: &HtbClient,
60 cmd: ChallengeCommand,
61 format: OutputFormat,
62) -> anyhow::Result<()> {
63 match cmd {
64 ChallengeCommand::List {
65 category,
66 state,
67 keyword,
68 page,
69 } => {
70 let result = client
71 .challenges()
72 .list(page.unwrap_or(1), 100, keyword.as_deref())
73 .await?;
74 let mut challenges = result.data;
75
76 if let Some(ref cat_filter) = category {
77 challenges.retain(|c| {
78 c.category_name
79 .as_ref()
80 .is_some_and(|cn| cn.eq_ignore_ascii_case(cat_filter))
81 });
82 }
83
84 if let Some(ref state_filter) = state {
85 challenges.retain(|c| {
86 c.state
87 .as_ref()
88 .is_some_and(|s| s.eq_ignore_ascii_case(state_filter))
89 });
90 }
91
92 output::print_list(&challenges, format);
93 output::print_pagination(
94 result.meta.current_page,
95 result.meta.last_page,
96 result.meta.total,
97 format,
98 );
99 }
100
101 ChallengeCommand::Categories => {
102 let categories = client.challenges().categories().await?;
103 output::print_list(&categories, format);
104 }
105
106 ChallengeCommand::Info { slug } => {
107 let detail = client.challenges().info(&slug).await?;
108 let fields = vec![
109 ("ID", detail.id.to_string()),
110 ("Name", detail.name.clone()),
111 ("Difficulty", detail.difficulty.clone().unwrap_or_default()),
112 ("Category", detail.category_name.clone().unwrap_or_default()),
113 (
114 "Points",
115 detail.points.clone().unwrap_or_else(|| "0".into()),
116 ),
117 (
118 "XP",
119 detail
120 .experience_points
121 .map(|xp| xp.to_string())
122 .unwrap_or_else(|| "0".into()),
123 ),
124 (
125 "Rating",
126 detail.stars.map(|s| format!("{s:.1}")).unwrap_or_default(),
127 ),
128 ("Solves", detail.solves.to_string()),
129 ("State", detail.state.clone().unwrap_or_default()),
130 (
131 "First Blood",
132 detail
133 .first_blood_user
134 .clone()
135 .unwrap_or_else(|| "-".into()),
136 ),
137 (
138 "Blood Time",
139 detail
140 .first_blood_time
141 .clone()
142 .unwrap_or_else(|| "-".into()),
143 ),
144 ("Creator", detail.creator_name.clone().unwrap_or_default()),
145 (
146 "Description",
147 detail.description.clone().unwrap_or_default(),
148 ),
149 ("Play Methods", detail.play_methods.join(", ")),
150 (
151 "Solved",
152 if detail.auth_user_solve { "Yes" } else { "No" }.into(),
153 ),
154 ];
155 output::print_detail(&detail, format, &fields);
156 }
157
158 ChallengeCommand::Download { slug } => {
159 let detail = client.challenges().info(&slug).await?;
160 let url = client.challenges().download_link(detail.id).await?;
161 let bytes = client.challenges().download_file(&url).await?;
162 let raw_name = detail.file_name.unwrap_or_else(|| format!("{slug}.zip"));
163 let filename = crate::sanitize_filename(&raw_name, &format!("{slug}.zip"));
164 std::fs::write(&filename, &bytes)?;
165 output::print_message(&format!("Downloaded {} ({} bytes)", filename, bytes.len()));
166 }
167
168 ChallengeCommand::Start { slug } => {
169 let detail = client.challenges().info(&slug).await?;
170 let resp = client.challenges().start(detail.id).await?;
171 output::print_message(&resp.message);
172 }
173
174 ChallengeCommand::Stop { slug } => {
175 let detail = client.challenges().info(&slug).await?;
176 let resp = client.challenges().stop(detail.id).await?;
177 output::print_message(&resp.message);
178 }
179
180 ChallengeCommand::Writeup { slug } => {
181 let detail = client.challenges().info(&slug).await?;
182 let writeup = client.challenges().writeup_info(detail.id).await?;
183 match writeup.data.official {
184 Some(w) if w.url.is_some() => {
185 let bytes = client.challenges().writeup_download(detail.id).await?;
186 let filename = w.filename.unwrap_or_else(|| format!("{slug}-writeup.pdf"));
187 let safe_name =
188 crate::sanitize_filename(&filename, &format!("{slug}-writeup.pdf"));
189 std::fs::write(&safe_name, &bytes)?;
190 output::print_message(&format!(
191 "Downloaded {} ({} bytes)",
192 safe_name,
193 bytes.len()
194 ));
195 }
196 _ => {
197 output::print_message("No official writeup available for this challenge.");
198 }
199 }
200 }
201
202 ChallengeCommand::Submit { id, flag } => {
203 let resp = client.challenges().submit_flag(id, &flag).await?;
204 output::print_message(&resp.message);
205 }
206 }
207 Ok(())
208}