Skip to main content

gitee_cli_rs/cmd/
interactive.rs

1use std::io::Write;
2use std::process::Command;
3
4use inquire::Text;
5
6use crate::config::Config;
7use crate::error::{GiteeError, Result};
8
9/// True when the user omitted `--title` and did not use `--fill` (PR only).
10pub fn should_run_interactive_create(title: Option<&str>, fill: bool) -> bool {
11    title.is_none() && !fill
12}
13
14pub fn missing_title_usage(subcommand: &str, mention_fill: bool) -> GiteeError {
15    if mention_fill {
16        GiteeError::Usage(format!("{subcommand} needs --title (or --fill)"))
17    } else {
18        GiteeError::Usage(format!("{subcommand} needs --title"))
19    }
20}
21
22/// Resolve the editor executable: `$VISUAL`, then `$EDITOR`, then config `editor`.
23pub fn resolve_editor_command(
24    visual: Option<&str>,
25    editor_env: Option<&str>,
26    config_editor: Option<&str>,
27) -> Option<String> {
28    visual
29        .filter(|s| !s.trim().is_empty())
30        .or_else(|| editor_env.filter(|s| !s.trim().is_empty()))
31        .or_else(|| config_editor.filter(|s| !s.trim().is_empty()))
32        .map(str::to_string)
33}
34
35pub fn prompt_title(default: Option<&str>) -> Result<String> {
36    let mut prompt = Text::new("Title");
37    if let Some(d) = default.filter(|s| !s.is_empty()) {
38        prompt = prompt.with_default(d);
39    }
40    let title = prompt
41        .prompt()
42        .map_err(|e| GiteeError::Usage(format!("prompt cancelled: {e}")))?;
43    let title = title.trim().to_string();
44    if title.is_empty() {
45        return Err(GiteeError::Usage("title is required".into()));
46    }
47    Ok(title)
48}
49
50/// Open `initial` in an external editor; returns `None` when the result is empty.
51pub fn edit_body_in_editor(initial: &str, editor_cmd: &str) -> Result<Option<String>> {
52    use shell_words::split;
53
54    let mut file = tempfile::NamedTempFile::new()?;
55    file.write_all(initial.as_bytes())?;
56    file.flush()?;
57    let path = file.path().to_owned();
58
59    let mut parts: Vec<String> = split(editor_cmd)
60        .map_err(|e| GiteeError::Usage(format!("invalid editor command: {e}")))?;
61    if parts.is_empty() {
62        return Err(GiteeError::Usage("editor command is empty".into()));
63    }
64    parts.push(path.to_string_lossy().into_owned());
65
66    let program = &parts[0];
67    let args = &parts[1..];
68    let status = Command::new(program)
69        .args(args)
70        .status()
71        .map_err(|e| GiteeError::Usage(format!("failed to run editor `{program}`: {e}")))?;
72    if !status.success() {
73        return Err(GiteeError::Usage("editor exited with an error".into()));
74    }
75
76    let body = std::fs::read_to_string(&path)?;
77    let trimmed = body.trim();
78    Ok((!trimmed.is_empty()).then(|| trimmed.to_string()))
79}
80
81pub fn resolve_editor_from_env_and_config() -> Result<String> {
82    let settings = Config::load_settings()?;
83    resolve_editor_command(
84        std::env::var("VISUAL").ok().as_deref(),
85        std::env::var("EDITOR").ok().as_deref(),
86        settings.editor.as_deref(),
87    )
88    .ok_or_else(|| {
89        GiteeError::Usage(
90            "no editor: set $VISUAL, $EDITOR, or `gitee config set editor <cmd>`".into(),
91        )
92    })
93}
94
95pub fn stdin_is_tty() -> bool {
96    use std::io::IsTerminal;
97    std::io::stdin().is_terminal()
98}
99
100/// Resolve an optional comment/issue body: use `--body`/`-m` when present;
101/// on a TTY open `$EDITOR` (prefilled with `prefill`); non-TTY without body
102/// is a usage error.
103pub fn resolve_optional_body(
104    body: Option<String>,
105    prefill: &str,
106    usage_cmd: &str,
107) -> Result<String> {
108    if let Some(b) = body {
109        return Ok(b);
110    }
111    if !stdin_is_tty() {
112        return Err(GiteeError::Usage(format!("{usage_cmd} needs --body/-m")));
113    }
114    let editor = resolve_editor_from_env_and_config()?;
115    edit_body_in_editor(prefill, &editor)?.ok_or_else(|| {
116        GiteeError::Usage("comment body is empty".into())
117    })
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn should_run_interactive_when_title_missing_and_not_fill() {
126        assert!(should_run_interactive_create(None, false));
127        assert!(!should_run_interactive_create(Some("t"), false));
128        assert!(!should_run_interactive_create(None, true));
129        assert!(!should_run_interactive_create(Some("t"), true));
130    }
131
132    #[test]
133    fn missing_title_usage_messages() {
134        assert_eq!(
135            missing_title_usage("issue create", false).to_string(),
136            "issue create needs --title"
137        );
138        assert_eq!(
139            missing_title_usage("pr create", true).to_string(),
140            "pr create needs --title (or --fill)"
141        );
142    }
143
144    #[test]
145    fn resolve_editor_prefers_visual_then_editor_then_config() {
146        assert_eq!(
147            resolve_editor_command(Some("visual"), Some("editor"), Some("config")),
148            Some("visual".into())
149        );
150        assert_eq!(
151            resolve_editor_command(None, Some("editor"), Some("config")),
152            Some("editor".into())
153        );
154        assert_eq!(
155            resolve_editor_command(None, None, Some("config")),
156            Some("config".into())
157        );
158        assert_eq!(resolve_editor_command(None, None, None), None);
159        assert_eq!(
160            resolve_editor_command(Some(""), Some("editor"), None),
161            Some("editor".into())
162        );
163        assert_eq!(
164            resolve_editor_command(Some("  "), None, Some("config")),
165            Some("config".into())
166        );
167    }
168}