diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go new file mode 100644 index 0000000000000000000000000000000000000000..15c747834129b06829fc46832e3e1a09538de3d5 --- /dev/null +++ b/internal/cmd/projects.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "encoding/json" + "os" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/charmbracelet/crush/internal/projects" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" +) + +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "List all tracked project directories", + Long: `List all directories where Crush has been used. +This includes the working directory, data directory path, and last accessed time.`, + Example: ` +# List all projects in a table +crush projects + +# Output as JSON +crush projects --json + `, + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + + projectList, err := projects.List() + if err != nil { + return err + } + + if jsonOutput { + output := struct { + Projects []projects.Project `json:"projects"` + }{Projects: projectList} + + data, err := json.Marshal(output) + if err != nil { + return err + } + cmd.Println(string(data)) + return nil + } + + if len(projectList) == 0 { + cmd.Println("No projects tracked yet.") + return nil + } + + if term.IsTerminal(os.Stdout.Fd()) { + // We're in a TTY: make it fancy. + t := table.New(). + Border(lipgloss.RoundedBorder()). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 2) + }). + Headers("Path", "Data Dir", "Last Accessed") + + for _, p := range projectList { + t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04")) + } + lipgloss.Println(t) + return nil + } + + // Not a TTY: plain output + for _, p := range projectList { + cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00")) + } + return nil + }, +} + +func init() { + projectsCmd.Flags().Bool("json", false, "Output as JSON") +} diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go new file mode 100644 index 0000000000000000000000000000000000000000..50585ea23582d5e40a75d1fab7e601161e7f3327 --- /dev/null +++ b/internal/cmd/projects_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/charmbracelet/crush/internal/projects" + "github.com/stretchr/testify/require" +) + +func TestProjectsEmpty(t *testing.T) { + // Use a temp directory for projects.json + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + var b bytes.Buffer + projectsCmd.SetOut(&b) + projectsCmd.SetErr(&b) + projectsCmd.SetIn(bytes.NewReader(nil)) + err := projectsCmd.RunE(projectsCmd, nil) + require.NoError(t, err) + require.Equal(t, "No projects tracked yet.\n", b.String()) +} + +func TestProjectsJSON(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project + err := projects.Register("/test/project", "/test/project/.crush") + require.NoError(t, err) + + var b bytes.Buffer + projectsCmd.SetOut(&b) + projectsCmd.SetErr(&b) + projectsCmd.SetIn(bytes.NewReader(nil)) + + // Set the --json flag + projectsCmd.Flags().Set("json", "true") + defer projectsCmd.Flags().Set("json", "false") + + err = projectsCmd.RunE(projectsCmd, nil) + require.NoError(t, err) + + // Parse the JSON output + var result struct { + Projects []projects.Project `json:"projects"` + } + err = json.Unmarshal(b.Bytes(), &result) + require.NoError(t, err) + + require.Len(t, result.Projects, 1) + require.Equal(t, "/test/project", result.Projects[0].Path) + require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f12b869dc772679a39ef1c306e20e77a91038a8c..90d64c7fccef6e27d2ebe8abb37445dc992b948f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" + "github.com/charmbracelet/crush/internal/projects" "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/version" @@ -40,6 +41,7 @@ func init() { rootCmd.AddCommand( runCmd, dirsCmd, + projectsCmd, updateProvidersCmd, logsCmd, schemaCmd, @@ -199,6 +201,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } + // Register this project in the centralized projects list. + if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil { + slog.Warn("Failed to register project", "error", err) + // Non-fatal: continue even if registration fails + } + // Connect to DB; this will also run migrations. conn, err := db.Connect(ctx, cfg.Options.DataDirectory) if err != nil { diff --git a/internal/projects/projects.go b/internal/projects/projects.go new file mode 100644 index 0000000000000000000000000000000000000000..f909fc2cf6e3e13030746cc8804ebc4af2514ba5 --- /dev/null +++ b/internal/projects/projects.go @@ -0,0 +1,126 @@ +package projects + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/charmbracelet/crush/internal/config" +) + +const projectsFileName = "projects.json" + +// Project represents a tracked project directory. +type Project struct { + Path string `json:"path"` + DataDir string `json:"data_dir"` + LastAccessed time.Time `json:"last_accessed"` +} + +// ProjectList holds the list of tracked projects. +type ProjectList struct { + Projects []Project `json:"projects"` +} + +var mu sync.Mutex + +// projectsFilePath returns the path to the projects.json file. +func projectsFilePath() string { + return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName) +} + +// Load reads the projects list from disk. +func Load() (*ProjectList, error) { + mu.Lock() + defer mu.Unlock() + + path := projectsFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &ProjectList{Projects: []Project{}}, nil + } + return nil, err + } + + var list ProjectList + if err := json.Unmarshal(data, &list); err != nil { + return nil, err + } + + return &list, nil +} + +// Save writes the projects list to disk. +func Save(list *ProjectList) error { + mu.Lock() + defer mu.Unlock() + + path := projectsFilePath() + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o600) +} + +// Register adds or updates a project in the list. +func Register(workingDir, dataDir string) error { + list, err := Load() + if err != nil { + return err + } + + now := time.Now().UTC() + + // Check if project already exists + found := false + for i, p := range list.Projects { + if p.Path == workingDir { + list.Projects[i].DataDir = dataDir + list.Projects[i].LastAccessed = now + found = true + break + } + } + + if !found { + list.Projects = append(list.Projects, Project{ + Path: workingDir, + DataDir: dataDir, + LastAccessed: now, + }) + } + + // Sort by last accessed (most recent first) + slices.SortFunc(list.Projects, func(a, b Project) int { + if a.LastAccessed.After(b.LastAccessed) { + return -1 + } + if a.LastAccessed.Before(b.LastAccessed) { + return 1 + } + return 0 + }) + + return Save(list) +} + +// List returns all tracked projects sorted by last accessed. +func List() ([]Project, error) { + list, err := Load() + if err != nil { + return nil, err + } + return list.Projects, nil +} diff --git a/internal/projects/projects_test.go b/internal/projects/projects_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2919410a4f57706d2e42e8cf760cfa8c7df43882 --- /dev/null +++ b/internal/projects/projects_test.go @@ -0,0 +1,180 @@ +package projects + +import ( + "path/filepath" + "testing" + "time" +) + +func TestRegisterAndList(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Override the projects file path for testing + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Test registering a project + err := Register("/home/user/project1", "/home/user/project1/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + // List projects + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/project1" { + t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/home/user/project1/.crush" { + t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir) + } + + // Register another project + err = Register("/home/user/project2", "/home/user/project2/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err = List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 2 { + t.Fatalf("Expected 2 projects, got %d", len(projects)) + } + + // Most recent should be first + if projects[0].Path != "/home/user/project2" { + t.Errorf("Expected most recent project first, got %s", projects[0].Path) + } +} + +func TestRegisterUpdatesExisting(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project + err := Register("/home/user/project1", "/home/user/project1/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, _ := List() + firstAccess := projects[0].LastAccessed + + // Wait a bit and re-register + time.Sleep(10 * time.Millisecond) + + err = Register("/home/user/project1", "/home/user/project1/.crush-new") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, _ = List() + + if len(projects) != 1 { + t.Fatalf("Expected 1 project after update, got %d", len(projects)) + } + + if projects[0].DataDir != "/home/user/project1/.crush-new" { + t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir) + } + + if !projects[0].LastAccessed.After(firstAccess) { + t.Error("Expected LastAccessed to be updated") + } +} + +func TestLoadEmptyFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // List before any projects exist + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 0 { + t.Errorf("Expected 0 projects, got %d", len(projects)) + } +} + +func TestProjectsFilePath(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + expected := filepath.Join(tmpDir, "crush", "projects.json") + actual := projectsFilePath() + + if actual != expected { + t.Errorf("Expected %s, got %s", expected, actual) + } +} + +func TestRegisterWithParentDataDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project where .crush is in a parent directory. + // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush + err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/monorepo/packages/app" { + t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/home/user/monorepo/.crush" { + t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir) + } +} + +func TestRegisterWithExternalDataDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project where .crush is in a completely different location. + // e.g., project at /home/user/project but data stored at /var/data/crush/myproject + err := Register("/home/user/project", "/var/data/crush/myproject") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/project" { + t.Errorf("Expected path /home/user/project, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/var/data/crush/myproject" { + t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir) + } +}