Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed20b6aba | ||
|
|
ffae335e62 | ||
|
|
32c3b30934 |
+59
-4
@@ -348,24 +348,79 @@ func dispatch(env *Env, name string, args []string) int {
|
|||||||
// 用法,靜默成功結束即可。
|
// 用法,靜默成功結束即可。
|
||||||
var errHelp = errors.New("help requested")
|
var errHelp = errors.New("help requested")
|
||||||
|
|
||||||
// parseFlags 剖析命令旗標:-h/--help 回 errHelp(由 exitCodeFor 視為成功),
|
// parseFlags 剖析命令旗標,允許旗標與位置參數交錯(#25):Go 標準
|
||||||
// 其他剖析錯誤回 ErrUsage。
|
// flag 在遇到第一個非旗標參數後即停止剖析,其後的旗標會被當成位置
|
||||||
|
// 參數。做法:先掃描 args 把「旗標(含其值)」與「位置參數」分離成
|
||||||
|
// 兩串,再以旗標串呼叫 fs.Parse,最後以第二次 Parse 把位置參數設回
|
||||||
|
// fs.Args(Parse 遇到 "--" 即停止並收下其餘引數,第一次剖析已設好
|
||||||
|
// 的旗標值不受影響)。
|
||||||
|
// -h/--help 回 errHelp(由 exitCodeFor 視為成功),其他剖析錯誤回 ErrUsage。
|
||||||
func parseFlags(fs *flag.FlagSet, args []string) error {
|
func parseFlags(fs *flag.FlagSet, args []string) error {
|
||||||
if err := fs.Parse(args); err != nil {
|
var flags, positional []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if arg == "--" {
|
||||||
|
// "--" 之後全部是位置參數(不再剖析旗標;同標準 flag)。
|
||||||
|
positional = append(positional, args[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(arg) < 2 || arg[0] != '-' {
|
||||||
|
positional = append(positional, arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flags = append(flags, arg)
|
||||||
|
// 判斷旗標是否需要「下一個引數」當值(布林旗標與 inline
|
||||||
|
// 形式 --name=value 不需要)。未知或格式錯誤的旗標不消費
|
||||||
|
// 下一個引數,交給 fs.Parse 產生標準錯誤訊息。
|
||||||
|
numMinuses := 1
|
||||||
|
if arg[1] == '-' {
|
||||||
|
numMinuses = 2
|
||||||
|
}
|
||||||
|
name := arg[numMinuses:]
|
||||||
|
if name == "" || name[0] == '-' || name[0] == '=' {
|
||||||
|
continue // ---x/--=…:bad flag syntax,由 fs.Parse 報錯
|
||||||
|
}
|
||||||
|
flagName, _, hasInline := strings.Cut(name, "=")
|
||||||
|
if !hasInline {
|
||||||
|
if fl := fs.Lookup(flagName); fl != nil && !isBoolFlag(fl) && i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
flags = append(flags, args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := fs.Parse(flags); err != nil {
|
||||||
if errors.Is(err, flag.ErrHelp) {
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
return errHelp
|
return errHelp
|
||||||
}
|
}
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return &ErrUsage{Msg: err.Error()}
|
||||||
}
|
}
|
||||||
|
if len(positional) > 0 {
|
||||||
|
if err := fs.Parse(append([]string{"--"}, positional...)); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return errHelp
|
||||||
|
}
|
||||||
|
return &ErrUsage{Msg: err.Error()}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isBoolFlag 回傳旗標是否為布林型(不需要值;同 flag 包內部判斷)。
|
||||||
|
func isBoolFlag(fl *flag.Flag) bool {
|
||||||
|
bf, ok := fl.Value.(interface{ IsBoolFlag() bool })
|
||||||
|
return ok && bf.IsBoolFlag()
|
||||||
|
}
|
||||||
|
|
||||||
// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3,
|
// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3,
|
||||||
// 其他(內部)→ 1。
|
// 其他(內部)→ 1。errHelp(命令的 -h/--help)視為成功(#25:
|
||||||
|
// 與 parseFlags 註解宣稱一致,FlagSet 已印出用法說明)。
|
||||||
func exitCodeFor(err error) ExitCode {
|
func exitCodeFor(err error) ExitCode {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ExitOK
|
return ExitOK
|
||||||
}
|
}
|
||||||
|
if err == errHelp {
|
||||||
|
return ExitOK
|
||||||
|
}
|
||||||
var usage *ErrUsage
|
var usage *ErrUsage
|
||||||
if errors.As(err, &usage) {
|
if errors.As(err, &usage) {
|
||||||
return ExitUsage
|
return ExitUsage
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// interleaved_flags_test.go — #25 的回歸測試:命令旗標放在位置參數
|
||||||
|
// (path/編號/名稱)之後也必須被剖析,不得被當成位置參數。
|
||||||
|
// 涵蓋 parseFlags 走的命令(issues/pulls view、api、stalled)與
|
||||||
|
// 原本直接 fs.Parse 的命令(login default/remove、members)。
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAPIFlagsAfterPath 驗證 #25 重現步驟一:teai api <path> --method POST
|
||||||
|
// --data '{}' --yes 的旗標在 path 之後仍被剖析(POST 送達伺服器而非 exit 2)。
|
||||||
|
func TestAPIFlagsAfterPath(t *testing.T) {
|
||||||
|
var gotMethod string
|
||||||
|
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.Method
|
||||||
|
fmt.Fprint(w, `{}`)
|
||||||
|
})
|
||||||
|
_, _, code := run("api", "/version", "--method", "POST", "--data", `{}`, "--yes")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("api 旗標在 path 之後應回 0(#25),得到 %d", code)
|
||||||
|
}
|
||||||
|
if gotMethod != "POST" {
|
||||||
|
t.Fatalf("應送出 POST,得到 %s", gotMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssuesViewCommentsFlagAfterNumber 驗證 #25 重現步驟二:
|
||||||
|
// teai issues view --repo owner/repo <編號> --comments 的 --comments
|
||||||
|
// 在編號之後仍生效(有打 /comments 端點)。
|
||||||
|
func TestIssuesViewCommentsFlagAfterNumber(t *testing.T) {
|
||||||
|
var commentsHit bool
|
||||||
|
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/comments"):
|
||||||
|
commentsHit = true
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/issues/8"):
|
||||||
|
fmt.Fprint(w, `{"number":8,"title":"t","state":"open","user":{"login":"ceo"},"body":"","html_url":"u","labels":[],"updated_at":"t"}`)
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stdout, _, code := run("issues", "view", "--repo", "alterminal/teai", "8", "--comments")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("issues view 編號在前旗標在後應回 0(#25),得到 %d", code)
|
||||||
|
}
|
||||||
|
if !commentsHit {
|
||||||
|
t.Fatalf("--comments 在編號之後應觸發 /comments 請求;輸出:%s", stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPullsViewCommentsFlagAfterNumber 同上,驗證 pulls view。
|
||||||
|
func TestPullsViewCommentsFlagAfterNumber(t *testing.T) {
|
||||||
|
var commentsHit bool
|
||||||
|
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/comments"):
|
||||||
|
commentsHit = true
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/pulls/13"):
|
||||||
|
fmt.Fprint(w, `{"number":13,"title":"t","state":"open","user":{"login":"ceo"},"body":"","html_url":"u","labels":[],"updated_at":"t"}`)
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_, _, code := run("pulls", "view", "--repo", "alterminal/teai", "13", "--comments")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("pulls view 編號在前旗標在後應回 0(#25),得到 %d", code)
|
||||||
|
}
|
||||||
|
if !commentsHit {
|
||||||
|
t.Fatal("--comments 在編號之後應觸發 /comments 請求")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStalledHoursFlagAfterPositional 驗證 stalled 即使混入位置參數
|
||||||
|
// 也不誤剖析:--hours 在位置參數後仍被剖析,而 stalled 本身不接受
|
||||||
|
// 位置參數 → 應回 2(用法錯誤)而非誤把 --hours 當位置參數。
|
||||||
|
func TestStalledHoursFlagAfterPositional(t *testing.T) {
|
||||||
|
run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
})
|
||||||
|
_, stderr, code := run("stalled", "9", "--hours", "3")
|
||||||
|
if code != 2 {
|
||||||
|
t.Fatalf("stalled 帶位置參數應回 2,得到 %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr, "位置參數") {
|
||||||
|
t.Fatalf("錯誤訊息應提及位置參數,得到:%s", stderr)
|
||||||
|
}
|
||||||
|
// 對照組:--hours 值非法(剖析層錯誤)應回 2,而非把旗標當位置參數。
|
||||||
|
_, stderr, code = run("stalled", "--hours", "abc")
|
||||||
|
if code != 2 || !strings.Contains(stderr, "invalid value") {
|
||||||
|
t.Fatalf("stalled --hours abc 應回 2 且提及 invalid value,得到 %d:%s", code, stderr)
|
||||||
|
}
|
||||||
|
// 交錯但無多餘位置參數:--hours 值不會被誤收。
|
||||||
|
_, stderr, code = run("stalled", "--hours")
|
||||||
|
if code != 2 || !strings.Contains(stderr, "flag needs an argument") {
|
||||||
|
t.Fatalf("stalled --hours 缺值應回 2 且提及 flag needs an argument,得到 %d:%s", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginDefaultNameOnly 驗證 login default <名稱>:位置參數在旗標
|
||||||
|
// 之後(--config 在前)仍正確收取一個名稱。
|
||||||
|
func TestLoginDefaultNameOnly(t *testing.T) {
|
||||||
|
cfg := `logins:
|
||||||
|
- name: a
|
||||||
|
url: https://a.example.com
|
||||||
|
token: ta
|
||||||
|
default: true
|
||||||
|
- name: b
|
||||||
|
url: https://b.example.com
|
||||||
|
token: tb
|
||||||
|
default: false
|
||||||
|
`
|
||||||
|
prefix, _ := loginTestEnv(t, cfg)
|
||||||
|
stdout, _, code := run(append(prefix, "login", "default", "b")...)
|
||||||
|
if code != 0 || !strings.Contains(stdout, `"name":"b"`) {
|
||||||
|
t.Fatalf("login default b 應回 0 並輸出 b:code=%d out=%s", code, stdout)
|
||||||
|
}
|
||||||
|
// 多給一個位置參數仍應報用法錯誤(不受交錯支援影響)。
|
||||||
|
_, stderr, code := run(append(prefix, "login", "default", "b", "c")...)
|
||||||
|
if code != 2 || !strings.Contains(stderr, "exactly one") {
|
||||||
|
t.Fatalf("兩個名稱應回 2,得到 %d:%s", code, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMemberHelpExitZero 驗證命令的 -h 結束碼為 0(#25 順帶修正:
|
||||||
|
// errHelp 視為成功)。
|
||||||
|
func TestMemberHelpExitZero(t *testing.T) {
|
||||||
|
_, _, code := run("members", "-h")
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("members -h 應回 0,得到 %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRemoveFlagInterleaved 驗證 login remove <名稱> --yes:
|
||||||
|
// --yes 在名稱之後仍視為旗標(未帶時走確認流程會讀 stdin)。
|
||||||
|
func TestLoginRemoveFlagInterleaved(t *testing.T) {
|
||||||
|
prefix, configPath := loginTestEnv(t, `logins:
|
||||||
|
- name: a
|
||||||
|
url: https://a.example.com
|
||||||
|
token: ta
|
||||||
|
default: true
|
||||||
|
`)
|
||||||
|
old := loginStdin
|
||||||
|
loginStdin = strings.NewReader("y\n") // 若 --yes 失效會讀到這行
|
||||||
|
defer func() { loginStdin = old }()
|
||||||
|
_, _, code := run(append(prefix, "login", "remove", "a", "--yes")...)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("login remove a --yes 應回 0,得到 %d", code)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(configPath)
|
||||||
|
if strings.Contains(string(data), "token: ta") {
|
||||||
|
t.Fatalf("--yes 在名稱之後應生效(直接刪除):\n%s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,8 +93,8 @@ func printLoginList(env *Env, logins []gitea.LoginEntry) error {
|
|||||||
func runLoginList(env *Env, args []string) error {
|
func runLoginList(env *Env, args []string) error {
|
||||||
fs := flag.NewFlagSet("teai login list", flag.ContinueOnError)
|
fs := flag.NewFlagSet("teai login list", flag.ContinueOnError)
|
||||||
fs.SetOutput(env.Err)
|
fs.SetOutput(env.Err)
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() > 0 {
|
if fs.NArg() > 0 {
|
||||||
return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))}
|
return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))}
|
||||||
@@ -121,8 +121,8 @@ func runLoginAdd(env *Env, args []string) error {
|
|||||||
nameFlag := fs.String("name", "", "登入名稱(省略時取 URL 主機)")
|
nameFlag := fs.String("name", "", "登入名稱(省略時取 URL 主機)")
|
||||||
tokenFlag := fs.String("token", "", "API token(省略時自 stdin 讀一行)")
|
tokenFlag := fs.String("token", "", "API token(省略時自 stdin 讀一行)")
|
||||||
defaultFlag := fs.Bool("default", true, "設為預設登入")
|
defaultFlag := fs.Bool("default", true, "設為預設登入")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() > 0 {
|
if fs.NArg() > 0 {
|
||||||
return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))}
|
return &ErrUsage{Msg: fmt.Sprintf("unexpected argument %q", fs.Arg(0))}
|
||||||
@@ -224,8 +224,8 @@ func verifyToken(env *Env, siteURL, token string) (string, error) {
|
|||||||
func runLoginDefault(env *Env, args []string) error {
|
func runLoginDefault(env *Env, args []string) error {
|
||||||
fs := flag.NewFlagSet("teai login default", flag.ContinueOnError)
|
fs := flag.NewFlagSet("teai login default", flag.ContinueOnError)
|
||||||
fs.SetOutput(env.Err)
|
fs.SetOutput(env.Err)
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
return &ErrUsage{Msg: "login default requires exactly one login name"}
|
return &ErrUsage{Msg: "login default requires exactly one login name"}
|
||||||
@@ -255,8 +255,8 @@ func runLoginRemove(env *Env, args []string) error {
|
|||||||
fs := flag.NewFlagSet("teai login remove", flag.ContinueOnError)
|
fs := flag.NewFlagSet("teai login remove", flag.ContinueOnError)
|
||||||
fs.SetOutput(env.Err)
|
fs.SetOutput(env.Err)
|
||||||
yesFlag := fs.Bool("yes", false, "跳過確認")
|
yesFlag := fs.Bool("yes", false, "跳過確認")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
return &ErrUsage{Msg: "login remove requires exactly one login name"}
|
return &ErrUsage{Msg: "login remove requires exactly one login name"}
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ func runMembers(env *Env, args []string) error {
|
|||||||
fs := flag.NewFlagSet("teai members", flag.ContinueOnError)
|
fs := flag.NewFlagSet("teai members", flag.ContinueOnError)
|
||||||
fs.SetOutput(env.Err)
|
fs.SetOutput(env.Err)
|
||||||
hasWork := fs.Bool("has-work", false, "只列出有未完成工作的成員")
|
hasWork := fs.Bool("has-work", false, "只列出有未完成工作的成員")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return &ErrUsage{Msg: "members 不接受位置參數"}
|
return &ErrUsage{Msg: "members 不接受位置參數"}
|
||||||
@@ -154,8 +154,8 @@ func runPulls(env *Env, args []string) error {
|
|||||||
mine := fs.Bool("mine", false, "限定作者是目前帳號")
|
mine := fs.Bool("mine", false, "限定作者是目前帳號")
|
||||||
reviewer := fs.Bool("reviewer", false, "限定自己是 requested reviewer")
|
reviewer := fs.Bool("reviewer", false, "限定自己是 requested reviewer")
|
||||||
repo := fs.String("repo", "", "限定單一倉庫(owner/repo)")
|
repo := fs.String("repo", "", "限定單一倉庫(owner/repo)")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := parseFlags(fs, args); err != nil {
|
||||||
return &ErrUsage{Msg: err.Error()}
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return &ErrUsage{Msg: "pulls 不接受位置參數"}
|
return &ErrUsage{Msg: "pulls 不接受位置參數"}
|
||||||
|
|||||||
Reference in New Issue
Block a user