mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
Merge pull request #85 from boolean-maybe/feature/config-command
config reset
This commit is contained in:
commit
73a95e2c5b
9 changed files with 789 additions and 3 deletions
118
.doc/doki/doc/command-line.md
Normal file
118
.doc/doki/doc/command-line.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Command line options
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
tiki [command] [options]
|
||||
```
|
||||
|
||||
Running `tiki` with no arguments launches the TUI in an initialized project.
|
||||
|
||||
## Commands
|
||||
|
||||
### init
|
||||
|
||||
Initialize a tiki project in the current git repository. Creates the `.doc/tiki/` directory structure for task storage.
|
||||
|
||||
```bash
|
||||
tiki init
|
||||
```
|
||||
|
||||
### exec
|
||||
|
||||
Execute a [ruki](ruki/index.md) query and exit. Requires an initialized project.
|
||||
|
||||
```bash
|
||||
tiki exec '<ruki-statement>'
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
tiki exec 'select where status = "ready" order by priority'
|
||||
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
|
||||
```
|
||||
|
||||
### config
|
||||
|
||||
Manage configuration files.
|
||||
|
||||
#### config reset
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
```bash
|
||||
tiki config reset [target] --scope
|
||||
```
|
||||
|
||||
**Targets** (omit to reset all three files):
|
||||
- `config` — config.yaml
|
||||
- `workflow` — workflow.yaml
|
||||
- `new` — new.md (task template)
|
||||
|
||||
**Scopes** (required):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
For `--global`, workflow.yaml and new.md are overwritten with embedded defaults. config.yaml is deleted (built-in defaults take over).
|
||||
|
||||
For `--local` and `--current`, files are deleted so the next tier in the [precedence chain](config.md#precedence-and-merging) takes effect.
|
||||
|
||||
```bash
|
||||
# restore all global config to defaults
|
||||
tiki config reset --global
|
||||
|
||||
# remove project workflow overrides (falls back to global)
|
||||
tiki config reset workflow --local
|
||||
|
||||
# remove cwd config override
|
||||
tiki config reset config --current
|
||||
```
|
||||
|
||||
### demo
|
||||
|
||||
Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused.
|
||||
|
||||
```bash
|
||||
tiki demo
|
||||
```
|
||||
|
||||
### sysinfo
|
||||
|
||||
Display system and terminal environment information useful for troubleshooting.
|
||||
|
||||
```bash
|
||||
tiki sysinfo
|
||||
```
|
||||
|
||||
## Markdown viewer
|
||||
|
||||
`tiki` doubles as a standalone markdown and image viewer. Pass a file path or URL as the first argument.
|
||||
|
||||
```bash
|
||||
tiki file.md
|
||||
tiki https://github.com/user/repo/blob/main/README.md
|
||||
tiki image.png
|
||||
echo "# Hello" | tiki -
|
||||
```
|
||||
|
||||
See [Markdown viewer](markdown-viewer.md) for navigation and keybindings.
|
||||
|
||||
## Piped input
|
||||
|
||||
When stdin is piped and no positional arguments are given, tiki creates a task from the input. The first line becomes the title; the rest becomes the description.
|
||||
|
||||
```bash
|
||||
echo "Fix the login bug" | tiki
|
||||
tiki < bug-report.md
|
||||
```
|
||||
|
||||
See [Quick capture](quick-capture.md) for more examples.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--help`, `-h` | Show usage information |
|
||||
| `--version`, `-v` | Show version, commit, and build date |
|
||||
| `--log-level <level>` | Set log level: `debug`, `info`, `warn`, `error` |
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Documentation
|
||||
- [Quick start](quick-start.md)
|
||||
- [Configuration](config.md)
|
||||
- [Installation](install.md)
|
||||
- [Configuration](config.md)
|
||||
- [Command line options](command-line.md)
|
||||
- [Markdown viewer](markdown-viewer.md)
|
||||
- [Image support](image-requirements.md)
|
||||
- [Custom fields](custom-fields.md)
|
||||
|
|
|
|||
125
cmd_config.go
Normal file
125
cmd_config.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runConfig dispatches config subcommands. Returns an exit code.
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runConfigReset(args[1:])
|
||||
case "--help", "-h":
|
||||
printConfigUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0])
|
||||
printConfigUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runConfigReset implements `tiki config reset [target] --scope`.
|
||||
func runConfigReset(args []string) int {
|
||||
target, scope, err := parseConfigResetArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printConfigResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printConfigResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseConfigResetArgs parses arguments for `tiki config reset`.
|
||||
func parseConfigResetArgs(args []string) (config.ResetTarget, config.ResetScope, error) {
|
||||
var targetStr string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if targetStr != "" {
|
||||
return "", "", fmt.Errorf("multiple targets specified: %q and %q", targetStr, arg)
|
||||
}
|
||||
targetStr = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
return "", "", fmt.Errorf("scope required: --global, --local, or --current")
|
||||
}
|
||||
|
||||
target := config.ResetTarget(targetStr)
|
||||
switch target {
|
||||
case config.TargetAll, config.TargetConfig, config.TargetWorkflow, config.TargetNew:
|
||||
// valid
|
||||
default:
|
||||
return "", "", fmt.Errorf("unknown target: %q (use config, workflow, or new)", targetStr)
|
||||
}
|
||||
|
||||
return target, config.ResetScope(scopeStr), nil
|
||||
}
|
||||
|
||||
func printConfigUsage() {
|
||||
fmt.Print(`Usage: tiki config <command>
|
||||
|
||||
Commands:
|
||||
reset [target] --scope Reset config files to defaults
|
||||
|
||||
Run 'tiki config reset --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printConfigResetUsage() {
|
||||
fmt.Print(`Usage: tiki config reset [target] --scope
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (required):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
117
cmd_config_test.go
Normal file
117
cmd_config_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
func TestParseConfigResetArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target config.ResetTarget
|
||||
scope config.ResetScope
|
||||
wantErr error // sentinel match (nil = no error)
|
||||
errSubstr string // substring match for non-sentinel errors
|
||||
}{
|
||||
{
|
||||
name: "global all",
|
||||
args: []string{"--global"},
|
||||
target: config.TargetAll,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local workflow",
|
||||
args: []string{"workflow", "--local"},
|
||||
target: config.TargetWorkflow,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "current config",
|
||||
args: []string{"--current", "config"},
|
||||
target: config.TargetConfig,
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "global new",
|
||||
args: []string{"new", "--global"},
|
||||
target: config.TargetNew,
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope",
|
||||
args: []string{"config"},
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "unknown target",
|
||||
args: []string{"themes", "--global"},
|
||||
errSubstr: "unknown target",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple targets",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
args: nil,
|
||||
errSubstr: "scope required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target, scope, err := parseConfigResetArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if target != tt.target {
|
||||
t.Errorf("target = %q, want %q", target, tt.target)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -324,6 +324,9 @@ func GetUserConfigWorkflowFile() string {
|
|||
return mustGetPathManager().UserConfigWorkflowFile()
|
||||
}
|
||||
|
||||
// configFilename is the default name for the configuration file
|
||||
const configFilename = "config.yaml"
|
||||
|
||||
// defaultWorkflowFilename is the default name for the workflow configuration file
|
||||
const defaultWorkflowFilename = "workflow.yaml"
|
||||
|
||||
|
|
|
|||
154
config/reset.go
Normal file
154
config/reset.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ResetScope identifies which config tier to reset.
|
||||
type ResetScope string
|
||||
|
||||
// ResetTarget identifies which config file to reset.
|
||||
type ResetTarget string
|
||||
|
||||
const (
|
||||
ScopeGlobal ResetScope = "global"
|
||||
ScopeLocal ResetScope = "local"
|
||||
ScopeCurrent ResetScope = "current"
|
||||
|
||||
TargetAll ResetTarget = ""
|
||||
TargetConfig ResetTarget = "config"
|
||||
TargetWorkflow ResetTarget = "workflow"
|
||||
TargetNew ResetTarget = "new"
|
||||
)
|
||||
|
||||
// resetEntry pairs a filename with the default content to restore for global scope.
|
||||
// If defaultContent is empty, the file is always deleted (no embedded default exists).
|
||||
type resetEntry struct {
|
||||
filename string
|
||||
defaultContent string
|
||||
}
|
||||
|
||||
var resetEntries = []resetEntry{
|
||||
// TODO: embed a default config.yaml once one exists; until then, global reset deletes the file
|
||||
{filename: configFilename, defaultContent: ""},
|
||||
{filename: defaultWorkflowFilename, defaultContent: defaultWorkflowYAML},
|
||||
{filename: templateFilename, defaultContent: defaultNewTaskTemplate},
|
||||
}
|
||||
|
||||
// ResetConfig resets configuration files for the given scope and target.
|
||||
// Returns the list of file paths that were actually modified or deleted.
|
||||
func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := filterEntries(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var affected []string
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(dir, e.filename)
|
||||
changed, err := resetFile(path, scope, e.defaultContent)
|
||||
if err != nil {
|
||||
return affected, fmt.Errorf("reset %s: %w", e.filename, err)
|
||||
}
|
||||
if changed {
|
||||
affected = append(affected, path)
|
||||
}
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// resolveDir returns the directory path for the given scope.
|
||||
func resolveDir(scope ResetScope) (string, error) {
|
||||
switch scope {
|
||||
case ScopeGlobal:
|
||||
return GetConfigDir(), nil
|
||||
case ScopeLocal:
|
||||
if !IsProjectInitialized() {
|
||||
return "", fmt.Errorf("not in an initialized tiki project (run 'tiki init' first)")
|
||||
}
|
||||
return GetProjectConfigDir(), nil
|
||||
case ScopeCurrent:
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
return cwd, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntries returns the reset entries matching the target.
|
||||
func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
||||
if target == TargetAll {
|
||||
return resetEntries, nil
|
||||
}
|
||||
var filename string
|
||||
switch target {
|
||||
case TargetConfig:
|
||||
filename = configFilename
|
||||
case TargetWorkflow:
|
||||
filename = defaultWorkflowFilename
|
||||
case TargetNew:
|
||||
filename = templateFilename
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown reset target: %q", target)
|
||||
}
|
||||
for _, e := range resetEntries {
|
||||
if e.filename == filename {
|
||||
return []resetEntry{e}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no reset entry for target %q", target)
|
||||
}
|
||||
|
||||
// resetFile either overwrites or deletes a file depending on scope and available defaults.
|
||||
// For global scope with non-empty default content, the file is overwritten.
|
||||
// Otherwise the file is deleted. Returns true if the file was changed.
|
||||
func resetFile(path string, scope ResetScope, defaultContent string) (bool, error) {
|
||||
if scope == ScopeGlobal && defaultContent != "" {
|
||||
return writeDefault(path, defaultContent)
|
||||
}
|
||||
return deleteIfExists(path)
|
||||
}
|
||||
|
||||
// writeDefault writes defaultContent to path, creating parent dirs if needed.
|
||||
// Returns true if the file was actually changed (skips write when content already matches).
|
||||
func writeDefault(path string, content string) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return false, fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for config file
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// deleteIfExists removes a file if it exists. Returns true if the file was deleted.
|
||||
func deleteIfExists(path string) (bool, error) {
|
||||
err := os.Remove(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
253
config/reset_test.go
Normal file
253
config/reset_test.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupResetTest creates a temp config dir, sets XDG_CONFIG_HOME, and resets
|
||||
// the path manager so GetConfigDir() points to the temp dir.
|
||||
// Returns the tiki config dir (e.g. <tmp>/tiki).
|
||||
func setupResetTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
// writeFile is a test helper that writes content to path.
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// seed all three files with custom content
|
||||
writeFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// config.yaml should be deleted (no embedded default)
|
||||
if _, err := os.Stat(filepath.Join(tikiDir, "config.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("config.yaml should be deleted after global reset")
|
||||
}
|
||||
|
||||
// workflow.yaml should contain embedded default
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml does not match embedded default after global reset")
|
||||
}
|
||||
|
||||
// new.md should contain embedded default
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultNewTaskTemplate() {
|
||||
t.Error("new.md does not match embedded default after global reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSingleTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
target ResetTarget
|
||||
filename string
|
||||
deleted bool // true = file deleted, false = file overwritten with default
|
||||
}{
|
||||
{TargetConfig, "config.yaml", true},
|
||||
{TargetWorkflow, "workflow.yaml", false},
|
||||
{TargetNew, "new.md", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.target), func(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
writeFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, tt.target)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(tikiDir, tt.filename))
|
||||
if tt.deleted {
|
||||
if !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should be deleted", tt.filename)
|
||||
}
|
||||
} else {
|
||||
if statErr != nil {
|
||||
t.Errorf("%s should exist after reset: %v", tt.filename, statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalDeletesFiles(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// set up project dir with .doc/tiki so IsProjectInitialized() passes
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(filepath.Join(docDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
// seed project config files
|
||||
writeFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
|
||||
// also write global defaults so we can verify local doesn't overwrite
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// all project files should be deleted
|
||||
for _, name := range []string{"config.yaml", "workflow.yaml", "new.md"} {
|
||||
if _, err := os.Stat(filepath.Join(docDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("project %s should be deleted after local reset", name)
|
||||
}
|
||||
}
|
||||
|
||||
// global workflow should be untouched
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("global workflow.yaml should still exist: %v", err)
|
||||
}
|
||||
if string(got) != "global\n" {
|
||||
t.Error("global workflow.yaml should be untouched after local reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
writeFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cwdDir, "workflow.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("cwd workflow.yaml should be deleted after current reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_IdempotentOnMissingFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
// reset when no files exist — should succeed with 0 affected
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files for missing config.yaml, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalWorkflowCreatesDir(t *testing.T) {
|
||||
// use a fresh temp dir where tiki subdir doesn't exist yet
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
// should have created the directory and written the default
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml should match embedded default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// write the embedded default content — reset should detect no change
|
||||
writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files when already default, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
|
||||
// point projectRoot at a temp dir that has no .doc/tiki
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = t.TempDir() // empty dir — not initialized
|
||||
|
||||
_, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uninitialized project, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, "not in an initialized tiki project") {
|
||||
t.Errorf("unexpected error message: %s", msg)
|
||||
}
|
||||
}
|
||||
|
|
@ -165,6 +165,11 @@ func GetDefaultNewTaskTemplate() string {
|
|||
return defaultNewTaskTemplate
|
||||
}
|
||||
|
||||
// GetDefaultWorkflowYAML returns the embedded default workflow.yaml content
|
||||
func GetDefaultWorkflowYAML() string {
|
||||
return defaultWorkflowYAML
|
||||
}
|
||||
|
||||
// InstallDefaultWorkflow installs the default workflow.yaml to the user config directory
|
||||
// if it does not already exist. This runs on every launch to handle first-run and upgrade cases.
|
||||
func InstallDefaultWorkflow() error {
|
||||
|
|
|
|||
14
main.go
14
main.go
|
|
@ -62,6 +62,11 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle config command
|
||||
if len(os.Args) > 1 && os.Args[1] == "config" {
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
}
|
||||
|
||||
// Handle exec command: execute ruki statement and exit
|
||||
if len(os.Args) > 1 && os.Args[1] == "exec" {
|
||||
os.Exit(runExec(os.Args[2:]))
|
||||
|
|
@ -83,7 +88,7 @@ func main() {
|
|||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
// "init" is reserved to prevent treating it as a markdown file
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}})
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "config": {}})
|
||||
if err != nil {
|
||||
if errors.Is(err, viewer.ErrMultipleInputs) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
|
@ -178,7 +183,11 @@ func runDemo() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// exit codes for tiki exec
|
||||
// errHelpRequested is returned by arg parsers when the user asks for help.
|
||||
// Callers should print usage and exit cleanly — not treat it as a real error.
|
||||
var errHelpRequested = errors.New("help requested")
|
||||
|
||||
// exit codes for CLI subcommands
|
||||
const (
|
||||
exitOK = 0
|
||||
exitInternal = 1
|
||||
|
|
@ -254,6 +263,7 @@ Usage:
|
|||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki exec '<statement>' Execute a ruki query and exit
|
||||
tiki config reset [target] Reset config files (--global, --local, --current)
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
|
|
|
|||
Loading…
Reference in a new issue