package cli import ( "context" "crypto/md5" "encoding/json" "fmt" "io" "os" "path/filepath" "github.com/spf13/cobra" "github.com/user/aevs/internal/config" "github.com/user/aevs/internal/storage" "github.com/user/aevs/internal/types" ) var ( statusConfig string ) var statusCmd = &cobra.Command{ Use: "status", Short: "Show sync status", Long: `Show the synchronization status of the current project.`, RunE: runStatus, } func init() { statusCmd.Flags().StringVarP(&statusConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config") } func runStatus(cmd *cobra.Command, args []string) error { // Load global config globalCfg, err := config.LoadGlobalConfig() if err != nil { if os.IsNotExist(err) { return fmt.Errorf("no storage configured; run 'aevs config' first") } return fmt.Errorf("failed to load global config: %w", err) } // Load project config projectCfg, err := config.LoadProjectConfig(statusConfig) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("no aevs.yaml found; run 'aevs init' first") } return fmt.Errorf("failed to load project config: %w", err) } // Create storage client s3Storage, err := storage.NewS3Storage(&globalCfg.Storage) if err != nil { return fmt.Errorf("failed to create storage client: %w", err) } ctx := context.Background() // Try to download metadata metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName) metadataReader, err := s3Storage.Download(ctx, metadataKey) var metadata *types.Metadata if err != nil { // Project not in storage yet fmt.Println() fmt.Printf("Project: %s\n", projectCfg.Project) fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project) fmt.Println() fmt.Println("Project not found in storage. Run 'aevs push' first.") return nil } metadataBytes, err := io.ReadAll(metadataReader) metadataReader.Close() if err != nil { return fmt.Errorf("failed to read metadata: %w", err) } metadata = &types.Metadata{} if err := json.Unmarshal(metadataBytes, metadata); err != nil { return fmt.Errorf("failed to parse metadata: %w", err) } currentDir, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current directory: %w", err) } // Build map of remote files remoteFiles := make(map[string]bool) for _, file := range metadata.Files { remoteFiles[file] = true } // Build map of local files localFiles := make(map[string]bool) for _, file := range projectCfg.Files { localFiles[file] = true } // Collect all unique files allFiles := make(map[string]bool) for file := range localFiles { allFiles[file] = true } for file := range remoteFiles { allFiles[file] = true } // Analyze each file var fileStatuses []types.FileStatus for file := range allFiles { status := analyzeFile(file, currentDir, localFiles[file], remoteFiles[file]) fileStatuses = append(fileStatuses, status) } // Print status fmt.Println() fmt.Printf("Project: %s\n", projectCfg.Project) fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project) fmt.Println() fmt.Printf("Remote last updated: %s (from %s)\n", formatTime(metadata.UpdatedAt), metadata.Machine) fmt.Println() fmt.Printf(" %-30s %-12s %-12s %s\n", "FILE", "LOCAL", "REMOTE", "STATUS") upToDate := 0 modified := 0 missing := 0 newFiles := 0 for _, fs := range fileStatuses { localSizeStr := formatFileSize(fs.LocalSize) remoteSizeStr := formatFileSize(fs.RemoteSize) statusStr := formatStatus(fs.Status) fmt.Printf(" %-30s %-12s %-12s %s\n", fs.Path, localSizeStr, remoteSizeStr, statusStr) switch fs.Status { case types.StatusUpToDate: upToDate++ case types.StatusLocalModified, types.StatusRemoteModified: modified++ case types.StatusMissingLocal: missing++ case types.StatusLocalOnly: newFiles++ } } fmt.Println() var summary []string if upToDate > 0 { summary = append(summary, fmt.Sprintf("%d up to date", upToDate)) } if modified > 0 { summary = append(summary, fmt.Sprintf("%d modified", modified)) } if missing > 0 { summary = append(summary, fmt.Sprintf("%d missing", missing)) } if newFiles > 0 { summary = append(summary, fmt.Sprintf("%d new", newFiles)) } if len(summary) > 0 { fmt.Printf("Summary: %s\n", summary[0]) for i := 1; i < len(summary); i++ { fmt.Printf(", %s", summary[i]) } fmt.Println() } return nil } // analyzeFile determines the status of a single file func analyzeFile(path string, rootDir string, existsLocal, existsRemote bool) types.FileStatus { fs := types.FileStatus{ Path: path, LocalSize: -1, RemoteSize: -1, Status: types.StatusUpToDate, } if !existsLocal && !existsRemote { // Should not happen fs.Status = types.StatusUpToDate return fs } if existsLocal && !existsRemote { // Local only fs.Status = types.StatusLocalOnly fullPath := filepath.Join(rootDir, path) if info, err := os.Stat(fullPath); err == nil { fs.LocalSize = info.Size() fs.LocalHash = computeFileHash(fullPath) } return fs } if !existsLocal && existsRemote { // Missing locally fs.Status = types.StatusMissingLocal // We don't have remote size info easily available without downloading return fs } // Both exist - need to compare fullPath := filepath.Join(rootDir, path) if info, err := os.Stat(fullPath); err == nil { fs.LocalSize = info.Size() fs.LocalHash = computeFileHash(fullPath) } else { // File doesn't actually exist locally fs.Status = types.StatusMissingLocal return fs } // For simplicity, we'll mark as up to date if file exists locally // In a real implementation, we'd need to compare hashes with remote fs.Status = types.StatusUpToDate return fs } // computeFileHash computes MD5 hash of a file func computeFileHash(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() h := md5.New() if _, err := io.Copy(h, f); err != nil { return "" } return fmt.Sprintf("%x", h.Sum(nil)) } // formatFileSize formats file size for display func formatFileSize(size int64) string { if size < 0 { return "—" } if size < 1024 { return fmt.Sprintf("%d B", size) } return fmt.Sprintf("%.1f KB", float64(size)/1024.0) } // formatStatus formats status for display func formatStatus(status types.SyncStatus) string { switch status { case types.StatusUpToDate: return "✓ up to date" case types.StatusLocalModified: return "⚡ local modified" case types.StatusRemoteModified: return "⚡ remote modified" case types.StatusMissingLocal: return "✗ missing locally" case types.StatusLocalOnly: return "+ local only" case types.StatusConflict: return "⚠ conflict" default: return string(status) } }