| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use crate::api::{MnwApiClient, UserInfo}; |
| 8 |
use crate::format; |
| 9 |
|
| 10 |
|
| 11 |
pub async fn execute( |
| 12 |
command_line: &str, |
| 13 |
user: &UserInfo, |
| 14 |
api: &MnwApiClient, |
| 15 |
) -> Vec<u8> { |
| 16 |
let parts: Vec<&str> = command_line.split_whitespace().collect(); |
| 17 |
if parts.is_empty() { |
| 18 |
return help_text(); |
| 19 |
} |
| 20 |
|
| 21 |
let json = parts.contains(&"--json"); |
| 22 |
let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect(); |
| 23 |
|
| 24 |
match parts[0] { |
| 25 |
"projects" => cmd_projects(user, api, json).await, |
| 26 |
"analytics" => { |
| 27 |
let range = parts |
| 28 |
.iter() |
| 29 |
.find_map(|p| p.strip_prefix("--range=")) |
| 30 |
.unwrap_or("30d"); |
| 31 |
cmd_analytics(user, api, range, json).await |
| 32 |
} |
| 33 |
"transactions" => cmd_transactions(user, api, json).await, |
| 34 |
"export" if parts.get(1) == Some(&"sales") => cmd_export_sales(user, api).await, |
| 35 |
"promo" => match parts.get(1).copied() { |
| 36 |
Some("list") => cmd_promo_list(user, api, json).await, |
| 37 |
Some("create") => { |
| 38 |
let code = parts.get(2).unwrap_or(&""); |
| 39 |
let pct = parts.get(3).unwrap_or(&"0"); |
| 40 |
cmd_promo_create(user, api, code, pct).await |
| 41 |
} |
| 42 |
_ => b"Usage: promo list | promo create CODE DISCOUNT_PCT\r\n".to_vec(), |
| 43 |
}, |
| 44 |
"blog" => match parts.get(1).copied() { |
| 45 |
Some("list") => { |
| 46 |
let slug = parts.get(2).unwrap_or(&""); |
| 47 |
cmd_blog_list(user, api, slug, json).await |
| 48 |
} |
| 49 |
_ => b"Usage: blog list <project-slug>\r\n".to_vec(), |
| 50 |
}, |
| 51 |
"help" | "--help" | "-h" => help_text(), |
| 52 |
other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n") |
| 53 |
.into_bytes(), |
| 54 |
} |
| 55 |
} |
| 56 |
|
| 57 |
async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { |
| 58 |
match api.get_projects(&user.user_id).await { |
| 59 |
Ok(projects) => { |
| 60 |
if json { |
| 61 |
return serde_json::to_vec_pretty(&projects).unwrap_or_default(); |
| 62 |
} |
| 63 |
if projects.is_empty() { |
| 64 |
return b"No projects.\r\n".to_vec(); |
| 65 |
} |
| 66 |
let mut out = format!( |
| 67 |
"{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", |
| 68 |
"Title", "Type", "Status", "Items", "Revenue" |
| 69 |
); |
| 70 |
out.push_str(&"-".repeat(70)); |
| 71 |
out.push_str("\r\n"); |
| 72 |
for p in &projects { |
| 73 |
let status = if p.is_public { "public" } else { "draft" }; |
| 74 |
let revenue = format::format_cents(p.revenue_cents); |
| 75 |
out.push_str(&format!( |
| 76 |
"{:<30} {:<12} {:<8} {:<6} {:<10}\r\n", |
| 77 |
truncate(&p.title, 29), |
| 78 |
p.project_type, |
| 79 |
status, |
| 80 |
p.item_count, |
| 81 |
revenue, |
| 82 |
)); |
| 83 |
} |
| 84 |
out.into_bytes() |
| 85 |
} |
| 86 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 87 |
} |
| 88 |
} |
| 89 |
|
| 90 |
async fn cmd_analytics( |
| 91 |
user: &UserInfo, |
| 92 |
api: &MnwApiClient, |
| 93 |
range: &str, |
| 94 |
json: bool, |
| 95 |
) -> Vec<u8> { |
| 96 |
match api.get_analytics(&user.user_id, range).await { |
| 97 |
Ok(data) => { |
| 98 |
if json { |
| 99 |
return serde_json::to_vec_pretty(&data).unwrap_or_default(); |
| 100 |
} |
| 101 |
|
| 102 |
let mut out = String::new(); |
| 103 |
out.push_str(&format!("Analytics ({range})\r\n\r\n")); |
| 104 |
|
| 105 |
let rev = format::format_cents(data.current_revenue_cents); |
| 106 |
let prev_rev = format::format_cents(data.previous_revenue_cents); |
| 107 |
out.push_str(&format!("Revenue: {rev} (prev: {prev_rev})\r\n")); |
| 108 |
out.push_str(&format!( |
| 109 |
"Sales: {} (prev: {})\r\n", |
| 110 |
data.current_sales, data.previous_sales |
| 111 |
)); |
| 112 |
out.push_str(&format!( |
| 113 |
"Followers: {} (prev: {})\r\n", |
| 114 |
data.current_followers, data.previous_followers |
| 115 |
)); |
| 116 |
|
| 117 |
if !data.top_projects.is_empty() { |
| 118 |
out.push_str("\r\nTop Projects:\r\n"); |
| 119 |
for p in &data.top_projects { |
| 120 |
out.push_str(&format!( |
| 121 |
" {:<30} {}\r\n", |
| 122 |
truncate(&p.title, 29), |
| 123 |
format::format_cents(p.revenue_cents) |
| 124 |
)); |
| 125 |
} |
| 126 |
} |
| 127 |
|
| 128 |
out.into_bytes() |
| 129 |
} |
| 130 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { |
| 135 |
match api.get_transactions(&user.user_id).await { |
| 136 |
Ok(txs) => { |
| 137 |
if json { |
| 138 |
return serde_json::to_vec_pretty(&txs).unwrap_or_default(); |
| 139 |
} |
| 140 |
if txs.is_empty() { |
| 141 |
return b"No transactions.\r\n".to_vec(); |
| 142 |
} |
| 143 |
let mut out = format!( |
| 144 |
"{:<30} {:<10} {:<12} {:<12}\r\n", |
| 145 |
"Item", "Amount", "Status", "Date" |
| 146 |
); |
| 147 |
out.push_str(&"-".repeat(66)); |
| 148 |
out.push_str("\r\n"); |
| 149 |
for tx in &txs { |
| 150 |
let title = tx.item_title.as_deref().unwrap_or("--"); |
| 151 |
let amount = format::format_cents(tx.amount_cents as i64); |
| 152 |
let date = tx.created_at.get(..10).unwrap_or(&tx.created_at); |
| 153 |
out.push_str(&format!( |
| 154 |
"{:<30} {:<10} {:<12} {:<12}\r\n", |
| 155 |
truncate(title, 29), |
| 156 |
amount, |
| 157 |
tx.status, |
| 158 |
date, |
| 159 |
)); |
| 160 |
} |
| 161 |
out.into_bytes() |
| 162 |
} |
| 163 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 164 |
} |
| 165 |
} |
| 166 |
|
| 167 |
async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> { |
| 168 |
match api.export_sales_csv(&user.user_id).await { |
| 169 |
Ok(result) => result.csv.into_bytes(), |
| 170 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> { |
| 175 |
match api.list_promo_codes(&user.user_id).await { |
| 176 |
Ok(codes) => { |
| 177 |
if json { |
| 178 |
return serde_json::to_vec_pretty(&codes).unwrap_or_default(); |
| 179 |
} |
| 180 |
if codes.is_empty() { |
| 181 |
return b"No promo codes.\r\n".to_vec(); |
| 182 |
} |
| 183 |
let mut out = format!( |
| 184 |
"{:<20} {:<12} {:<20} {:<10}\r\n", |
| 185 |
"Code", "Discount", "Scope", "Uses" |
| 186 |
); |
| 187 |
out.push_str(&"-".repeat(64)); |
| 188 |
out.push_str("\r\n"); |
| 189 |
for c in &codes { |
| 190 |
let discount = match (c.discount_type.as_deref(), c.discount_value) { |
| 191 |
(Some("percentage"), Some(v)) => format!("{}% off", v), |
| 192 |
(Some("fixed"), Some(v)) => format!("${}.{:02} off", v / 100, v % 100), |
| 193 |
_ => "Free".to_string(), |
| 194 |
}; |
| 195 |
let scope = c |
| 196 |
.item_title |
| 197 |
.as_deref() |
| 198 |
.or(c.project_title.as_deref()) |
| 199 |
.unwrap_or("All items"); |
| 200 |
let uses = match c.max_uses { |
| 201 |
Some(max) => format!("{}/{}", c.use_count, max), |
| 202 |
None => c.use_count.to_string(), |
| 203 |
}; |
| 204 |
out.push_str(&format!( |
| 205 |
"{:<20} {:<12} {:<20} {:<10}\r\n", |
| 206 |
truncate(&c.code, 19), |
| 207 |
discount, |
| 208 |
truncate(scope, 19), |
| 209 |
uses, |
| 210 |
)); |
| 211 |
} |
| 212 |
out.into_bytes() |
| 213 |
} |
| 214 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 215 |
} |
| 216 |
} |
| 217 |
|
| 218 |
async fn cmd_promo_create( |
| 219 |
user: &UserInfo, |
| 220 |
api: &MnwApiClient, |
| 221 |
code: &str, |
| 222 |
pct: &str, |
| 223 |
) -> Vec<u8> { |
| 224 |
if code.is_empty() { |
| 225 |
return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec(); |
| 226 |
} |
| 227 |
let discount: i32 = pct.parse().unwrap_or(0); |
| 228 |
match api |
| 229 |
.create_promo_code(&user.user_id, code, "percentage", discount, None, None) |
| 230 |
.await |
| 231 |
{ |
| 232 |
Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(), |
| 233 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 234 |
} |
| 235 |
} |
| 236 |
|
| 237 |
async fn cmd_blog_list( |
| 238 |
user: &UserInfo, |
| 239 |
api: &MnwApiClient, |
| 240 |
slug: &str, |
| 241 |
json: bool, |
| 242 |
) -> Vec<u8> { |
| 243 |
if slug.is_empty() { |
| 244 |
return b"Usage: blog list <project-slug>\r\n".to_vec(); |
| 245 |
} |
| 246 |
|
| 247 |
|
| 248 |
let projects = match api.get_projects(&user.user_id).await { |
| 249 |
Ok(p) => p, |
| 250 |
Err(e) => return format!("Error: {e}\r\n").into_bytes(), |
| 251 |
}; |
| 252 |
|
| 253 |
let Some(project) = projects.iter().find(|p| p.slug == slug) else { |
| 254 |
return format!("Project not found: {slug}\r\n").into_bytes(); |
| 255 |
}; |
| 256 |
|
| 257 |
match api.list_blog_posts(&user.user_id, &project.id).await { |
| 258 |
Ok(posts) => { |
| 259 |
if json { |
| 260 |
return serde_json::to_vec_pretty(&posts).unwrap_or_default(); |
| 261 |
} |
| 262 |
if posts.is_empty() { |
| 263 |
return b"No blog posts.\r\n".to_vec(); |
| 264 |
} |
| 265 |
let mut out = format!( |
| 266 |
"{:<30} {:<20} {:<10} {:<12}\r\n", |
| 267 |
"Title", "Slug", "Status", "Created" |
| 268 |
); |
| 269 |
out.push_str(&"-".repeat(74)); |
| 270 |
out.push_str("\r\n"); |
| 271 |
for p in &posts { |
| 272 |
let status = if p.is_published { "published" } else { "draft" }; |
| 273 |
let date = p.created_at.get(..10).unwrap_or(&p.created_at); |
| 274 |
out.push_str(&format!( |
| 275 |
"{:<30} {:<20} {:<10} {:<12}\r\n", |
| 276 |
truncate(&p.title, 29), |
| 277 |
truncate(&p.slug, 19), |
| 278 |
status, |
| 279 |
date, |
| 280 |
)); |
| 281 |
} |
| 282 |
out.into_bytes() |
| 283 |
} |
| 284 |
Err(e) => format!("Error: {e}\r\n").into_bytes(), |
| 285 |
} |
| 286 |
} |
| 287 |
|
| 288 |
fn help_text() -> Vec<u8> { |
| 289 |
b"Usage: ssh cli.makenot.work <command>\r\n\ |
| 290 |
\r\n\ |
| 291 |
Commands:\r\n\ |
| 292 |
\x20 projects List your projects\r\n\ |
| 293 |
\x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\ |
| 294 |
\x20 transactions Recent transactions\r\n\ |
| 295 |
\x20 export sales Export sales as CSV\r\n\ |
| 296 |
\x20 promo list List promo codes\r\n\ |
| 297 |
\x20 promo create CODE PCT Create a promo code\r\n\ |
| 298 |
\x20 blog list SLUG List blog posts for project\r\n\ |
| 299 |
\r\n\ |
| 300 |
Add --json to any command for machine-readable output.\r\n" |
| 301 |
.to_vec() |
| 302 |
} |
| 303 |
|
| 304 |
fn truncate(s: &str, max_len: usize) -> &str { |
| 305 |
if s.len() <= max_len { |
| 306 |
s |
| 307 |
} else { |
| 308 |
&s[..s.floor_char_boundary(max_len)] |
| 309 |
} |
| 310 |
} |
| 311 |
|
| 312 |
#[cfg(test)] |
| 313 |
mod tests { |
| 314 |
use super::*; |
| 315 |
|
| 316 |
#[test] |
| 317 |
fn truncate_short_string() { |
| 318 |
assert_eq!(truncate("hello", 10), "hello"); |
| 319 |
} |
| 320 |
|
| 321 |
#[test] |
| 322 |
fn truncate_exact_length() { |
| 323 |
assert_eq!(truncate("hello", 5), "hello"); |
| 324 |
} |
| 325 |
|
| 326 |
#[test] |
| 327 |
fn truncate_long_string() { |
| 328 |
assert_eq!(truncate("hello world", 5), "hello"); |
| 329 |
} |
| 330 |
|
| 331 |
#[test] |
| 332 |
fn truncate_multibyte_utf8() { |
| 333 |
|
| 334 |
let result = truncate("café", 4); |
| 335 |
assert_eq!(result, "caf"); |
| 336 |
} |
| 337 |
|
| 338 |
#[test] |
| 339 |
fn truncate_emoji() { |
| 340 |
|
| 341 |
let result = truncate("🎵🎶🎸", 5); |
| 342 |
assert_eq!(result, "🎵"); |
| 343 |
} |
| 344 |
} |
| 345 |
|