Skip to content

Commit 0c5c1b5

Browse files
authored
Merge pull request #322 from neongreen/copilot/add-import-single-setting
Add single setting import to conf import command
2 parents 057a6f5 + 59c50eb commit 0c5c1b5

3 files changed

Lines changed: 228 additions & 5 deletions

File tree

conf/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,20 @@ conf import
105105
# Import specific tool only
106106
conf import jj
107107

108+
# Import a single setting from a tool
109+
conf import claude model
110+
conf import jj user.name
111+
108112
# Preview what would be imported
109113
conf import --dry-run
114+
conf import jj user.email --dry-run
110115
```
111116

112117
This is useful for:
113118
- Migrating existing configurations to conf management
114119
- Capturing manual changes made to config files
115120
- Setting up conf on a new machine with existing configs
121+
- Selectively importing individual settings without affecting others
116122

117123
### State Management
118124

conf/cmd/import.go

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/jedib0t/go-pretty/v6/table"
99
"github.com/jedib0t/go-pretty/v6/text"
1010
"github.com/neongreen/mono/conf/pkg/config"
11+
"github.com/neongreen/mono/conf/pkg/tools"
1112
claudetool "github.com/neongreen/mono/conf/pkg/tools/claude"
1213
jjtool "github.com/neongreen/mono/conf/pkg/tools/jj"
1314
misetool "github.com/neongreen/mono/conf/pkg/tools/mise"
@@ -17,7 +18,7 @@ import (
1718
)
1819

1920
var importCmd = &cobra.Command{
20-
Use: "import [tool]",
21+
Use: "import [tool] [path]",
2122
Short: "Import configuration values from target files into conf state",
2223
Long: `Read configuration values from target files (e.g., ~/.config/jj/config.toml)
2324
and import them into conf's state management (stored in ~/.config/conf/).
@@ -28,17 +29,33 @@ This is useful for:
2829
- Syncing local configurations into conf state
2930
3031
Examples:
31-
conf import # Import all tools
32-
conf import jj # Import only jj config
33-
conf import --dry-run # Preview what would be imported`,
34-
Args: cobra.MaximumNArgs(1),
32+
conf import # Import all tools
33+
conf import jj # Import only jj config
34+
conf import claude foo.bar # Import only claude's foo.bar setting
35+
conf import --dry-run # Preview what would be imported`,
36+
Args: cobra.MaximumNArgs(2),
3537
Run: func(cmd *cobra.Command, args []string) {
3638
conf, err := config.Load()
3739
if err != nil {
3840
fmt.Fprintf(os.Stderr, "Error: Failed to load conf config: %v\n", err)
3941
os.Exit(1)
4042
}
4143

44+
// Case 1: Import a specific setting from a tool
45+
if len(args) == 2 {
46+
toolName := args[0]
47+
configPath := args[1]
48+
if err := importToolSetting(conf, toolName, configPath, dryRun); err != nil {
49+
fmt.Fprintf(os.Stderr, "Error importing %s.%s: %v\n", toolName, configPath, err)
50+
os.Exit(1)
51+
}
52+
if !dryRun {
53+
fmt.Println("\n✓ Import complete")
54+
}
55+
return
56+
}
57+
58+
// Case 2: Import all settings from a tool or all tools
4259
var toolsToImport []string
4360
if len(args) == 1 {
4461
toolsToImport = []string{args[0]}
@@ -115,6 +132,61 @@ func importTool(conf *config.Config, toolName string, dryRun bool) error {
115132
return nil
116133
}
117134

135+
// importToolSetting imports a specific configuration value from a tool's target file into conf's state
136+
func importToolSetting(conf *config.Config, toolName string, configPath string, dryRun bool) error {
137+
tool, exists := conf.GetTool(toolName)
138+
if !exists {
139+
return fmt.Errorf("tool %s not configured", toolName)
140+
}
141+
142+
fmt.Printf("Importing %s.%s from %s...\n", toolName, configPath, tool.ConfigPath)
143+
144+
// Get the specific value from the target config file using the registry
145+
value, err := tools.GetActualValue(toolName, configPath)
146+
if err != nil {
147+
return fmt.Errorf("failed to read target config: %w", err)
148+
}
149+
150+
// Get the current value from conf state
151+
existingFlat := config.FlattenValues(tool.Values)
152+
currentValue, hasCurrent := existingFlat[configPath]
153+
154+
// Determine status
155+
var status string
156+
if !hasCurrent {
157+
status = cli.Success("NEW")
158+
} else if fmt.Sprintf("%v", value) == fmt.Sprintf("%v", currentValue) {
159+
status = cli.Muted("SAME")
160+
} else {
161+
status = cli.Warning("UPDATE")
162+
}
163+
164+
// Display what would be/will be imported
165+
renderSingleSettingImport(configPath, status, value, currentValue)
166+
167+
if dryRun {
168+
fmt.Printf("\nWould import: %s.%s = %v\n", toolName, configPath, value)
169+
return nil
170+
}
171+
172+
fmt.Printf("\n ✓ Imported %s.%s = %v\n", toolName, configPath, value)
173+
174+
// Convert the dotted path to nested map structure
175+
nestedValues := config.ExpandValues(map[string]any{configPath: value})
176+
177+
// Merge into conf state
178+
conf.MergeToolValues(toolName, nestedValues)
179+
180+
// Save conf state
181+
if err := conf.Save(); err != nil {
182+
return fmt.Errorf("failed to save conf state: %w", err)
183+
}
184+
185+
fmt.Printf(" ✓ Saved to conf state\n")
186+
187+
return nil
188+
}
189+
118190
// getTargetConfigValues reads all values from a tool's target config file
119191
func getTargetConfigValues(toolName string) (map[string]any, error) {
120192
switch toolName {
@@ -216,3 +288,22 @@ func renderImportPreview(toolName string, existingFlat map[string]any, incomingF
216288
cli.Mutedf("%d", same),
217289
)
218290
}
291+
292+
// renderSingleSettingImport renders a table showing a single setting to be imported
293+
func renderSingleSettingImport(configPath string, status string, incomingValue any, currentValue any) {
294+
t := cli.NewTable(os.Stdout)
295+
t.AppendHeader(table.Row{"Path", "Status", "Incoming", "Current"})
296+
t.SetColumnConfigs([]table.ColumnConfig{
297+
{Number: 1, WidthMax: 42},
298+
{Number: 2, WidthMax: 10},
299+
{Number: 3, WidthMax: 50, WidthMaxEnforcer: text.WrapSoft},
300+
{Number: 4, WidthMax: 50, WidthMaxEnforcer: text.WrapSoft},
301+
})
302+
t.AppendRow(table.Row{
303+
cli.Key(configPath),
304+
status,
305+
cli.Value(formatValueShort(incomingValue)),
306+
cli.Muted(formatValueShort(currentValue)),
307+
})
308+
t.Render()
309+
}

conf/cmd/integration_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,132 @@ normal = "status"
650650
}
651651
}
652652
})
653+
654+
// Test importing a single setting
655+
t.Run("import single setting", func(t *testing.T) {
656+
// Create a new test home for this test
657+
testHome3 := t.TempDir()
658+
659+
// Create test jj config with multiple values
660+
jjConfigDir3 := filepath.Join(testHome3, ".config", "jj")
661+
if err := os.MkdirAll(jjConfigDir3, 0o755); err != nil {
662+
t.Fatalf("Failed to create jj config dir: %v", err)
663+
}
664+
665+
jjConfigMultiple := `[user]
666+
name = "Single Import Test"
667+
email = "single@example.com"
668+
669+
[snapshot]
670+
max-new-file-size = 4096
671+
`
672+
if err := os.WriteFile(filepath.Join(jjConfigDir3, "config.toml"), []byte(jjConfigMultiple), 0o644); err != nil {
673+
t.Fatalf("Failed to write jj config: %v", err)
674+
}
675+
676+
// Import only user.name
677+
cmd := exec.Command(binaryPath, "import", "jj", "user.name")
678+
cmd.Env = append(os.Environ(), "HOME="+testHome3)
679+
680+
var stdout, stderr bytes.Buffer
681+
cmd.Stdout = &stdout
682+
cmd.Stderr = &stderr
683+
684+
if err := cmd.Run(); err != nil {
685+
t.Errorf("Import single setting failed: %v", err)
686+
t.Logf("stdout: %s", stdout.String())
687+
t.Logf("stderr: %s", stderr.String())
688+
}
689+
690+
output := stdout.String()
691+
692+
// Verify only user.name is imported
693+
if !strings.Contains(output, "user.name") {
694+
t.Errorf("Expected import output to show user.name, got: %s", output)
695+
}
696+
if !strings.Contains(output, "Single Import Test") {
697+
t.Errorf("Expected import output to show the value 'Single Import Test', got: %s", output)
698+
}
699+
if !strings.Contains(output, "✓ Import complete") {
700+
t.Errorf("Expected completion message, got: %s", output)
701+
}
702+
703+
// Verify conf state file was created and contains only user.name
704+
confStateFile := filepath.Join(testHome3, ".config", "conf", "jj.toml")
705+
if _, err := os.Stat(confStateFile); os.IsNotExist(err) {
706+
t.Errorf("Expected conf state file to be created at %s", confStateFile)
707+
} else {
708+
content, err := os.ReadFile(confStateFile)
709+
if err != nil {
710+
t.Errorf("Failed to read conf state file: %v", err)
711+
} else {
712+
contentStr := string(content)
713+
// Should contain user.name but not the other values
714+
if !strings.Contains(contentStr, "Single Import Test") {
715+
t.Errorf("Expected conf state to contain 'Single Import Test', got: %s", contentStr)
716+
}
717+
// Should NOT contain user.email or snapshot settings since we only imported user.name
718+
if strings.Contains(contentStr, "single@example.com") {
719+
t.Errorf("Expected conf state to NOT contain 'single@example.com' (not imported), got: %s", contentStr)
720+
}
721+
if strings.Contains(contentStr, "4096") {
722+
t.Errorf("Expected conf state to NOT contain '4096' (not imported), got: %s", contentStr)
723+
}
724+
}
725+
}
726+
})
727+
728+
// Test importing a single setting with dry-run
729+
t.Run("import single setting dry-run", func(t *testing.T) {
730+
// Create a new test home for this test
731+
testHome4 := t.TempDir()
732+
733+
// Create test jj config
734+
jjConfigDir4 := filepath.Join(testHome4, ".config", "jj")
735+
if err := os.MkdirAll(jjConfigDir4, 0o755); err != nil {
736+
t.Fatalf("Failed to create jj config dir: %v", err)
737+
}
738+
739+
jjConfigSingle := `[user]
740+
email = "dryrun@example.com"
741+
`
742+
if err := os.WriteFile(filepath.Join(jjConfigDir4, "config.toml"), []byte(jjConfigSingle), 0o644); err != nil {
743+
t.Fatalf("Failed to write jj config: %v", err)
744+
}
745+
746+
// Dry-run import of user.email
747+
cmd := exec.Command(binaryPath, "import", "jj", "user.email", "--dry-run")
748+
cmd.Env = append(os.Environ(), "HOME="+testHome4)
749+
750+
var stdout, stderr bytes.Buffer
751+
cmd.Stdout = &stdout
752+
cmd.Stderr = &stderr
753+
754+
if err := cmd.Run(); err != nil {
755+
t.Errorf("Import single setting dry-run failed: %v", err)
756+
t.Logf("stdout: %s", stdout.String())
757+
t.Logf("stderr: %s", stderr.String())
758+
}
759+
760+
output := stdout.String()
761+
762+
// Verify dry-run output
763+
if !strings.Contains(output, "user.email") {
764+
t.Errorf("Expected dry-run output to show user.email, got: %s", output)
765+
}
766+
if !strings.Contains(output, "dryrun@example.com") {
767+
t.Errorf("Expected dry-run output to show the value 'dryrun@example.com', got: %s", output)
768+
}
769+
if !strings.Contains(output, "Would import: jj.user.email = dryrun@example.com") {
770+
t.Errorf("Expected dry-run message, got: %s", output)
771+
}
772+
773+
// Verify conf state file was NOT created in dry-run mode
774+
confStateFile := filepath.Join(testHome4, ".config", "conf", "jj.toml")
775+
if _, err := os.Stat(confStateFile); !os.IsNotExist(err) {
776+
t.Errorf("Expected conf state file to NOT be created in dry-run mode, but it exists at %s", confStateFile)
777+
}
778+
})
653779
}
654780

655781
// TestApplyPreservesUnmanagedSettings tests that `conf apply` preserves

0 commit comments

Comments
 (0)