chub_cli/commands/
feedback.rs1use clap::Args;
2use owo_colors::OwoColorize;
3
4use chub_core::identity::get_or_create_client_id;
5use chub_core::registry::{get_entry, MergedRegistry};
6use chub_core::telemetry::{
7 get_telemetry_url, is_feedback_enabled, is_telemetry_enabled, send_feedback, FeedbackOpts,
8 VALID_LABELS,
9};
10
11use crate::output;
12
13#[derive(Args)]
14pub struct FeedbackArgs {
15 id: Option<String>,
17
18 rating: Option<String>,
20
21 comment: Option<String>,
23
24 #[arg(long, name = "type")]
26 entry_type: Option<String>,
27
28 #[arg(long)]
30 lang: Option<String>,
31
32 #[arg(long)]
34 doc_version: Option<String>,
35
36 #[arg(long)]
38 file: Option<String>,
39
40 #[arg(long = "label")]
42 labels: Vec<String>,
43
44 #[arg(long)]
46 agent: Option<String>,
47
48 #[arg(long)]
50 model: Option<String>,
51
52 #[arg(long)]
54 status: bool,
55}
56
57pub async fn run(args: FeedbackArgs, json: bool, merged: Option<&MergedRegistry>) {
58 if args.status {
59 run_status(json).await;
60 return;
61 }
62
63 let id = match &args.id {
64 Some(id) => id.clone(),
65 None => {
66 output::error(
67 "Missing required arguments: <id> and <rating>. Run: chub feedback <id> <up|down> [comment]",
68 json,
69 );
70 std::process::exit(1);
71 }
72 };
73
74 let rating = match &args.rating {
75 Some(r) => r.clone(),
76 None => {
77 output::error(
78 "Missing required arguments: <id> and <rating>. Run: chub feedback <id> <up|down> [comment]",
79 json,
80 );
81 std::process::exit(1);
82 }
83 };
84
85 if rating != "up" && rating != "down" {
86 output::error("Rating must be \"up\" or \"down\".", json);
87 std::process::exit(1);
88 }
89
90 if !is_feedback_enabled() {
91 if json {
92 println!(
93 "{}",
94 serde_json::json!({ "status": "skipped", "reason": "feedback_disabled" })
95 );
96 } else {
97 eprintln!(
98 "{}",
99 "Feedback is disabled. Enable with: feedback: true in ~/.chub/config.yaml".yellow()
100 );
101 }
102 return;
103 }
104
105 let mut entry_type = args.entry_type.clone();
107 let mut doc_lang = args.lang.clone();
108 let mut doc_version = args.doc_version.clone();
109 let mut source = None;
110
111 if let Some(merged) = merged {
112 let result = get_entry(&id, merged);
113 if let Some(ref entry) = result.entry {
114 if entry_type.is_none() {
115 entry_type = Some(entry.entry_type.to_string());
116 }
117 source = Some(entry.source_name.clone());
118
119 if let Some(languages) = entry.languages() {
120 if doc_lang.is_none() && languages.len() == 1 {
121 doc_lang = Some(languages[0].language.clone());
122 }
123 if doc_version.is_none() {
124 let lang_obj = languages
125 .iter()
126 .find(|l| Some(&l.language) == doc_lang.as_ref())
127 .or(languages.first());
128 if let Some(l) = lang_obj {
129 doc_version = Some(l.recommended_version.clone());
130 }
131 }
132 }
133 }
134 }
135 let entry_type = entry_type.unwrap_or_else(|| "doc".to_string());
136
137 let labels: Option<Vec<String>> = if args.labels.is_empty() {
139 None
140 } else {
141 let valid: Vec<String> = args
142 .labels
143 .iter()
144 .map(|l| l.trim().to_lowercase())
145 .filter(|l| VALID_LABELS.contains(&l.as_str()))
146 .collect();
147 if valid.is_empty() {
148 None
149 } else {
150 Some(valid)
151 }
152 };
153
154 let result = send_feedback(
155 &id,
156 &entry_type,
157 &rating,
158 FeedbackOpts {
159 comment: args.comment,
160 doc_lang: doc_lang.clone(),
161 doc_version: doc_version.clone(),
162 target_file: args.file.clone(),
163 labels,
164 agent: args.agent,
165 model: args.model,
166 cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
167 source,
168 },
169 )
170 .await;
171
172 if json {
173 println!(
174 "{}",
175 serde_json::to_string_pretty(&result).unwrap_or_default()
176 );
177 } else if result.status == "sent" {
178 let mut parts = vec![format!("Feedback recorded for {}", id).green().to_string()];
179 if let Some(ref lang) = doc_lang {
180 parts.push(format!("lang={}", lang).dimmed().to_string());
181 }
182 if let Some(ref ver) = doc_version {
183 parts.push(format!("version={}", ver).dimmed().to_string());
184 }
185 if let Some(ref file) = args.file {
186 parts.push(format!("file={}", file).dimmed().to_string());
187 }
188 eprintln!("{}", parts.join(" "));
189 } else if result.status == "error" {
190 let reason = result
191 .reason
192 .as_deref()
193 .or(result.code.map(|_| "HTTP error").as_ref().map(|s| *s))
194 .unwrap_or("unknown");
195 eprintln!("{}", format!("Failed to send feedback: {}", reason).red());
196 }
197}
198
199async fn run_status(json: bool) {
200 let feedback_enabled = is_feedback_enabled();
201 let telemetry_enabled = is_telemetry_enabled();
202
203 if json {
204 let client_id = get_or_create_client_id();
205 println!(
206 "{}",
207 serde_json::json!({
208 "feedback": feedback_enabled,
209 "telemetry": telemetry_enabled,
210 "client_id_prefix": client_id.as_deref().map(|s| &s[..s.len().min(8)]),
211 "endpoint": get_telemetry_url().unwrap_or_default(),
212 "valid_labels": VALID_LABELS,
213 })
214 );
215 } else {
216 let fb = if feedback_enabled {
217 "enabled".green().to_string()
218 } else {
219 "disabled".red().to_string()
220 };
221 let tl = if telemetry_enabled {
222 "enabled".green().to_string()
223 } else {
224 "disabled".red().to_string()
225 };
226 eprintln!("Feedback: {}", fb);
227 eprintln!("Telemetry: {}", tl);
228 if let Some(cid) = get_or_create_client_id() {
229 let prefix = &cid[..cid.len().min(8)];
230 eprintln!("Client ID: {}...", prefix);
231 }
232 let endpoint = get_telemetry_url()
233 .unwrap_or_else(|| "(local only — set telemetry_url to enable remote)".to_string());
234 eprintln!("Endpoint: {}", endpoint);
235 eprintln!("Labels: {}", VALID_LABELS.join(", "));
236 }
237}