Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ HWP/HWPX → Stage 1 (Parser) → IR → Stage 2 (LLM, optional) → Markdown
| `HWP2MD_TIMEOUT` | LLM request timeout (Go duration: `5m`, `300s`, `10m30s`). Empty → provider default |
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `UPSTAGE_API_KEY` | Provider API keys |

Setting precedence (highest → lowest): CLI flag → env var → `~/.hwp2md/config.yaml` → built-in default.

## Config File Keys (`hwp2md config set`)

| Key | Type | Notes |
|-----|------|-------|
| `parser` | string | `native` or `upstage` |
| `llm.enabled` | bool | Mirrors `--llm` / `HWP2MD_LLM` |
| `llm.provider` | string | `openai`, `anthropic`, `gemini`, `upstage`, `ollama` |
| `llm.model` | string | Mirrors `--model` / `HWP2MD_MODEL` |
| `llm.base_url` | string | Mirrors `--base-url` / `HWP2MD_BASE_URL` |
| `llm.timeout` | duration string | Mirrors `--timeout` / `HWP2MD_TIMEOUT` (e.g. `5m`) |

## Conventions

- Korean is the primary language for CLI messages, comments, and documentation
Expand Down
196 changes: 188 additions & 8 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"

"github.com/roboco-io/hwp2md/internal/config"
)

func TestSetVersion(t *testing.T) {
Expand Down Expand Up @@ -246,44 +248,222 @@ func TestParseLLMTimeout(t *testing.T) {
name string
flag string
env string
cfg string
want time.Duration
wantErr bool
wantErrContain string // optional substring expected in the error message
}{
{name: "both empty falls back to provider default", flag: "", env: "", want: 0},
{name: "all empty falls back to provider default", want: 0},
{name: "flag value parsed", flag: "5m", want: 5 * time.Minute},
{name: "env value parsed when flag empty", env: "300s", want: 300 * time.Second},
{name: "config value parsed when flag/env empty", cfg: "2m", want: 2 * time.Minute},
{name: "flag overrides env", flag: "10m", env: "30s", want: 10 * time.Minute},
{name: "flag overrides config", flag: "10m", cfg: "30s", want: 10 * time.Minute},
{name: "env overrides config", env: "30s", cfg: "5m", want: 30 * time.Second},
{name: "compound duration", flag: "10m30s", want: 10*time.Minute + 30*time.Second},
{name: "whitespace trimmed", flag: " 2m ", want: 2 * time.Minute},
{name: "whitespace flag falls back to env", flag: " ", env: "5m", want: 5 * time.Minute},
{name: "whitespace env returns provider default", env: "\t", want: 0},
{name: "whitespace flag/env falls back to config", flag: " ", env: "\t", cfg: "1m", want: time.Minute},
{name: "whitespace everywhere returns provider default", env: "\t", cfg: " ", want: 0},
{name: "invalid flag is reported as flag", flag: "abc", env: "5m", wantErr: true, wantErrContain: "--timeout"},
{name: "invalid env is reported as env", env: "abc", wantErr: true, wantErrContain: "HWP2MD_TIMEOUT"},
{name: "invalid config is reported as config", cfg: "abc", wantErr: true, wantErrContain: "llm.timeout"},
{name: "invalid env returns error", env: "5", wantErr: true},
{name: "zero rejected", flag: "0s", wantErr: true, wantErrContain: "양수"},
{name: "negative rejected", flag: "-1s", wantErr: true, wantErrContain: "양수"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseLLMTimeout(tc.flag, tc.env)
got, err := parseLLMTimeout(tc.flag, tc.env, tc.cfg)
if tc.wantErr {
if err == nil {
t.Fatalf("parseLLMTimeout(%q, %q) expected error, got nil", tc.flag, tc.env)
t.Fatalf("parseLLMTimeout(%q, %q, %q) expected error, got nil", tc.flag, tc.env, tc.cfg)
}
if tc.wantErrContain != "" && !strings.Contains(err.Error(), tc.wantErrContain) {
t.Errorf("parseLLMTimeout(%q, %q) error %q does not contain %q",
tc.flag, tc.env, err.Error(), tc.wantErrContain)
t.Errorf("parseLLMTimeout(%q, %q, %q) error %q does not contain %q",
tc.flag, tc.env, tc.cfg, err.Error(), tc.wantErrContain)
}
return
}
if err != nil {
t.Fatalf("parseLLMTimeout(%q, %q) unexpected error: %v", tc.flag, tc.env, err)
t.Fatalf("parseLLMTimeout(%q, %q, %q) unexpected error: %v", tc.flag, tc.env, tc.cfg, err)
}
if got != tc.want {
t.Errorf("parseLLMTimeout(%q, %q, %q) = %v, want %v", tc.flag, tc.env, tc.cfg, got, tc.want)
}
})
}
}

func TestResolveString(t *testing.T) {
tests := []struct {
name string
flag, env, cfg, defaultVal, want string
}{
{"all empty falls back to default", "", "", "", "native", "native"},
{"flag wins", "F", "E", "C", "D", "F"},
{"env wins when flag empty", "", "E", "C", "D", "E"},
{"config wins when flag/env empty", "", "", "C", "D", "C"},
{"whitespace treated as empty", " ", "\t", "C", "D", "C"},
{"all empty with empty default", "", "", "", "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := resolveString(tc.flag, tc.env, tc.cfg, tc.defaultVal)
if got != tc.want {
t.Errorf("parseLLMTimeout(%q, %q) = %v, want %v", tc.flag, tc.env, got, tc.want)
t.Errorf("resolveString(%q,%q,%q,%q) = %q, want %q",
tc.flag, tc.env, tc.cfg, tc.defaultVal, got, tc.want)
}
})
}
}

func TestRunConfigSet_NewKeys(t *testing.T) {
tests := []struct {
name string
key string
value string
check func(*testing.T, *config.Config)
expectErr bool
}{
{
name: "parser native",
key: "parser",
value: "native",
check: func(t *testing.T, cfg *config.Config) {
if cfg.Parser != "native" {
t.Errorf("Parser = %q, want %q", cfg.Parser, "native")
}
},
},
{
name: "parser invalid",
key: "parser",
value: "bogus",
expectErr: true,
},
{
name: "llm.enabled true",
key: "llm.enabled",
value: "true",
check: func(t *testing.T, cfg *config.Config) {
if !cfg.LLM.Enabled {
t.Error("LLM.Enabled = false, want true")
}
},
},
{
name: "llm.enabled invalid",
key: "llm.enabled",
value: "yesno",
expectErr: true,
},
{
name: "llm.provider upstage",
key: "llm.provider",
value: "upstage",
check: func(t *testing.T, cfg *config.Config) {
if cfg.LLM.Provider != "upstage" {
t.Errorf("LLM.Provider = %q, want %q", cfg.LLM.Provider, "upstage")
}
},
},
{
name: "llm.provider invalid",
key: "llm.provider",
value: "claude",
expectErr: true,
},
{
name: "llm.model",
key: "llm.model",
value: "gpt-4o",
check: func(t *testing.T, cfg *config.Config) {
if cfg.LLM.Model != "gpt-4o" {
t.Errorf("LLM.Model = %q, want %q", cfg.LLM.Model, "gpt-4o")
}
},
},
{
name: "llm.model empty",
key: "llm.model",
value: " ",
expectErr: true,
},
{
name: "llm.base_url valid",
key: "llm.base_url",
value: "http://localhost:11434",
check: func(t *testing.T, cfg *config.Config) {
if cfg.LLM.BaseURL != "http://localhost:11434" {
t.Errorf("LLM.BaseURL = %q, want %q", cfg.LLM.BaseURL, "http://localhost:11434")
}
},
},
{
name: "llm.base_url no scheme",
key: "llm.base_url",
value: "localhost:11434",
expectErr: true,
},
{
name: "llm.timeout 5m",
key: "llm.timeout",
value: "5m",
check: func(t *testing.T, cfg *config.Config) {
if cfg.LLM.Timeout != "5m" {
t.Errorf("LLM.Timeout = %q, want %q", cfg.LLM.Timeout, "5m")
}
},
},
{
name: "llm.timeout zero rejected",
key: "llm.timeout",
value: "0s",
expectErr: true,
},
{
name: "llm.timeout invalid",
key: "llm.timeout",
value: "abc",
expectErr: true,
},
{
name: "unknown key",
key: "bogus.key",
value: "1",
expectErr: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tmpHome := t.TempDir()
// Cover both Unix (HOME) and Windows (USERPROFILE) home lookups
// so config.NewLoader() never touches the real user home.
t.Setenv("HOME", tmpHome)
t.Setenv("USERPROFILE", tmpHome)

err := runConfigSet(configSetCmd, []string{tc.key, tc.value})
if tc.expectErr {
if err == nil {
t.Fatalf("runConfigSet(%q,%q) expected error, got nil", tc.key, tc.value)
}
return
}
if err != nil {
t.Fatalf("runConfigSet(%q,%q) unexpected error: %v", tc.key, tc.value, err)
}
loader, err := config.NewLoader()
if err != nil {
t.Fatalf("NewLoader: %v", err)
}
cfg, err := loader.LoadRaw()
if err != nil {
t.Fatalf("LoadRaw: %v", err)
}
tc.check(t, cfg)
})
}
}
65 changes: 61 additions & 4 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package cli

import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"

"github.com/roboco-io/hwp2md/internal/config"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -51,13 +54,23 @@ var configSetCmd = &cobra.Command{
Long: `설정 값을 변경합니다.

지원하는 키:
default_provider 기본 LLM 프로바이더 (anthropic, openai, gemini, ollama)
parser 파서 선택 (native, upstage)
llm.enabled LLM 활성화 (true/false)
llm.provider LLM 프로바이더 (openai, anthropic, gemini, upstage, ollama)
llm.model LLM 모델 이름
llm.base_url 프라이빗 API 엔드포인트
llm.timeout LLM 요청 타임아웃 (예: 5m, 300s)
default_provider (호환) 기본 LLM 프로바이더
format.temperature LLM 온도 (0.0-1.0)
format.language 출력 언어 (ko, en)

예시:
hwp2md config set default_provider openai
hwp2md config set format.temperature 0.5`,
hwp2md config set parser native
hwp2md config set llm.enabled true
hwp2md config set llm.provider openai
hwp2md config set llm.model gpt-4o-mini
hwp2md config set llm.base_url http://localhost:11434
hwp2md config set llm.timeout 5m`,
Args: cobra.ExactArgs(2),
RunE: runConfigSet,
}
Expand Down Expand Up @@ -179,6 +192,50 @@ func runConfigSet(cmd *cobra.Command, args []string) error {

// Update config based on key
switch key {
case "parser":
validParsers := []string{"native", "upstage"}
if !contains(validParsers, value) {
return fmt.Errorf("유효하지 않은 파서: %s (지원: %s)", value, strings.Join(validParsers, ", "))
}
cfg.Parser = value

case "llm.enabled":
enabled, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("유효하지 않은 bool 값: %s (true/false)", value)
}
cfg.LLM.Enabled = enabled

case "llm.provider":
validProviders := []string{"openai", "anthropic", "gemini", "upstage", "ollama"}
if !contains(validProviders, value) {
return fmt.Errorf("유효하지 않은 프로바이더: %s (지원: %s)", value, strings.Join(validProviders, ", "))
}
cfg.LLM.Provider = value

case "llm.model":
if strings.TrimSpace(value) == "" {
return fmt.Errorf("모델 이름이 비어 있습니다")
}
cfg.LLM.Model = value

case "llm.base_url":
u, err := url.Parse(value)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("유효하지 않은 URL: %s", value)
}
cfg.LLM.BaseURL = value

case "llm.timeout":
d, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("유효하지 않은 타임아웃 값: %s (Go 기간 형식 필요, 예: 5m, 300s)", value)
}
if d <= 0 {
return fmt.Errorf("타임아웃은 양수여야 합니다: %s", value)
}
cfg.LLM.Timeout = value

case "default_provider":
validProviders := []string{"anthropic", "openai", "gemini", "ollama"}
if !contains(validProviders, value) {
Expand All @@ -204,7 +261,7 @@ func runConfigSet(cmd *cobra.Command, args []string) error {
cfg.Format.Language = value

default:
return fmt.Errorf("알 수 없는 설정 키: %s\n지원하는 키: default_provider, format.temperature, format.language", key)
return fmt.Errorf("알 수 없는 설정 키: %s\n지원하는 키: parser, llm.enabled, llm.provider, llm.model, llm.base_url, llm.timeout, default_provider, format.temperature, format.language", key)
}

if err := loader.Save(cfg); err != nil {
Expand Down
Loading
Loading