Repository: dundee/gdu
Pull request #21 (2021-01-23 23:37:01)

Non-interactive mode for listing devices

codecov[bot]: # [Codecov](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=h1) Report
> Merging [#21](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=desc) (5f102ae) into [master](https://codecov.io/gh/Dundee/gdu/commit/1280c82d20eb3a4ea42d70009d71b8133404355a?el=desc) (1280c82) will **increase** coverage by `4.61%`.
> The diff coverage is `80.24%`.

[![Impacted file tree graph](https://codecov.io/gh/Dundee/gdu/pull/21/graphs/tree.svg?width=650&height=150&src=pr&token=4zcK7hGXWy)](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #21      +/-   ##
==========================================
+ Coverage   84.18%   88.79%   +4.61%     
==========================================
  Files           9        8       -1     
  Lines         588      607      +19     
==========================================
+ Hits          495      539      +44     
+ Misses         67       37      -30     
- Partials       26       31       +5     
```


| [Impacted Files](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [analyze/dir.go](https://codecov.io/gh/Dundee/gdu/pull/21/diff?src=pr&el=tree#diff-YW5hbHl6ZS9kaXIuZ28=) | `96.15% <ø> (ø)` | |
| [cmd/run.go](https://codecov.io/gh/Dundee/gdu/pull/21/diff?src=pr&el=tree#diff-Y21kL3J1bi5nbw==) | `72.72% <72.72%> (ø)` | |
| [stdout/stdout.go](https://codecov.io/gh/Dundee/gdu/pull/21/diff?src=pr&el=tree#diff-c3Rkb3V0L3N0ZG91dC5nbw==) | `89.18% <88.88%> (-0.15%)` | :arrow_down: |
| [tui/tui.go](https://codecov.io/gh/Dundee/gdu/pull/21/diff?src=pr&el=tree#diff-dHVpL3R1aS5nbw==) | `88.34% <100.00%> (-1.23%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=footer). Last update [1280c82...5f102ae](https://codecov.io/gh/Dundee/gdu/pull/21?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/analyze/dev.go b/analyze/dev.go
index b84822d..5696b00 100644
--- a/analyze/dev.go
+++ b/analyze/dev.go
@@ -7,3 +7,6 @@ type Device struct {
 	Size       int64
 	Free       int64
 }
+
+// DevicesInfoGetter is type for GetDevicesInfo function
+type DevicesInfoGetter func(mountsPath string) ([]*Device, error)
diff --git a/analyze/dir.go b/analyze/dir.go
index 3f25f4e..107c61e 100644
--- a/analyze/dir.go
+++ b/analyze/dir.go
@@ -20,7 +20,7 @@ type CurrentProgress struct {
 // ShouldDirBeIgnored whether path should be ignored
 type ShouldDirBeIgnored func(path string) bool
 
-// Analyzer is type for dir analyzing funcion
+// Analyzer is type for dir analyzing function
 type Analyzer func(path string, progress *CurrentProgress, ignore ShouldDirBeIgnored) *File
 
 // ProcessDir analyzes given path
diff --git a/cmd/root.go b/cmd/root.go
deleted file mode 100644
index 1820cf5..0000000
--- a/cmd/root.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"os"
-
-	"github.com/mattn/go-isatty"
-	"github.com/spf13/cobra"
-)
-
-// Execute adds all child commands to the root command and sets flags appropriately.
-// This is called by main.main(). It only needs to happen once to the rootCmd.
-func Execute() {
-	sf := &scanFlags{}
-
-	var rootCmd = &cobra.Command{
-		Use:   "gdu [directory_to_scan]",
-		Short: "Pretty fast disk usage analyzer written in Go",
-		Long: `Pretty fast disk usage analyzer written in Go.
-
-Gdu is intended primarily for SSD disks where it can fully utilize parallel processing.
-However HDDs work as well, but the performance gain is not so huge.
-`,
-		Args: cobra.MaximumNArgs(1),
-		Run: func(cmd *cobra.Command, args []string) {
-			istty := isatty.IsTerminal(os.Stdout.Fd())
-			scan(sf, args, istty, os.Stdout)
-		},
-	}
-
-	flags := rootCmd.Flags()
-	flags.StringVarP(&sf.logFile, "log-file", "l", "/dev/null", "Path to a logfile")
-	flags.StringSliceVarP(&sf.ignoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, "Absolute paths to ignore (separated by comma)")
-	flags.BoolVarP(&sf.showDisks, "show-disks", "d", false, "Show all mounted disks")
-	flags.BoolVarP(&sf.showVersion, "version", "v", false, "Print version")
-	flags.BoolVarP(&sf.noColor, "no-color", "c", false, "Do not use colorized output")
-	flags.BoolVarP(&sf.nonInteractive, "non-interactive", "n", false, "Do not run in interactive mode")
-	flags.BoolVarP(&sf.noProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode")
-
-	if err := rootCmd.Execute(); err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-}
diff --git a/cmd/run.go b/cmd/run.go
new file mode 100644
index 0000000..94662f3
--- /dev/null
+++ b/cmd/run.go
@@ -0,0 +1,96 @@
+package cmd
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"runtime"
+
+	"github.com/dundee/gdu/analyze"
+	"github.com/dundee/gdu/build"
+	"github.com/dundee/gdu/stdout"
+	"github.com/dundee/gdu/tui"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+// RunFlags define flags accepted by Run
+type RunFlags struct {
+	LogFile        string
+	IgnoreDirs     []string
+	ShowDisks      bool
+	ShowVersion    bool
+	NoColor        bool
+	NonInteractive bool
+	NoProgress     bool
+}
+
+// Run starts gdu main logic
+func Run(flags *RunFlags, args []string, istty bool, writer io.Writer, testing bool) {
+	if flags.ShowVersion {
+		fmt.Fprintln(writer, "Version:\t", build.Version)
+		fmt.Fprintln(writer, "Built time:\t", build.Time)
+		fmt.Fprintln(writer, "Built user:\t", build.User)
+		return
+	}
+
+	var path string
+	var ui tui.CommonUI
+
+	f, err := os.OpenFile(flags.LogFile, os.O_RDWR|os.O_CREATE, 0644)
+	if err != nil {
+		log.Fatalf("error opening file: %v", err)
+	}
+	defer f.Close()
+	log.SetOutput(f)
+
+	if len(args) == 1 {
+		path = args[0]
+	} else {
+		path = "."
+	}
+
+	if flags.NonInteractive || !istty {
+		ui = stdout.CreateStdoutUI(writer, !flags.NoColor && istty, !flags.NoProgress && istty)
+	} else {
+		var screen tcell.Screen
+
+		if testing {
+			screen = tcell.NewSimulationScreen("UTF-8")
+		} else {
+			screen, err = tcell.NewScreen()
+			if err != nil {
+				panic(err)
+			}
+		}
+		screen.Init()
+
+		ui = tui.CreateUI(screen, !flags.NoColor)
+
+		if !flags.NoColor {
+			tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227)
+		}
+	}
+
+	if flags.ShowDisks {
+		if runtime.GOOS == "linux" {
+			ui.ListDevices(analyze.GetDevicesInfo)
+		} else {
+			fmt.Fprint(writer, "Listing devices is not yet supported for this platform")
+			return
+		}
+	} else {
+		ui.SetIgnoreDirPaths(flags.IgnoreDirs)
+		ui.AnalyzePath(path, analyze.ProcessDir, nil)
+	}
+
+	if testing {
+		return
+	}
+
+	switch ui.(type) {
+	case *tui.UI:
+		ui.(*tui.UI).StartUILoop()
+	}
+}
diff --git a/cmd/scan.go b/cmd/scan.go
deleted file mode 100644
index aaf8b08..0000000
--- a/cmd/scan.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package cmd
-
-import (
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"runtime"
-
-	"github.com/dundee/gdu/analyze"
-	"github.com/dundee/gdu/build"
-	"github.com/dundee/gdu/stdout"
-	"github.com/dundee/gdu/tui"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type scanFlags struct {
-	logFile        string
-	ignoreDirs     []string
-	showDisks      bool
-	showVersion    bool
-	noColor        bool
-	nonInteractive bool
-	noProgress     bool
-}
-
-func scan(flags *scanFlags, args []string, istty bool, writer io.Writer) {
-	if flags.showVersion {
-		fmt.Fprintln(writer, "Version:\t", build.Version)
-		fmt.Fprintln(writer, "Built time:\t", build.Time)
-		fmt.Fprintln(writer, "Built user:\t", build.User)
-		return
-	}
-
-	var path string
-
-	f, err := os.OpenFile(flags.logFile, os.O_RDWR|os.O_CREATE, 0644)
-	if err != nil {
-		log.Fatalf("error opening file: %v", err)
-	}
-	defer f.Close()
-	log.SetOutput(f)
-
-	if len(args) == 1 {
-		path = args[0]
-	} else {
-		path = "."
-	}
-
-	if flags.nonInteractive || !istty {
-		ui := stdout.CreateStdoutUI(writer, !flags.noColor && istty, !flags.noProgress && istty)
-		ui.SetIgnoreDirPaths(flags.ignoreDirs)
-		ui.AnalyzePath(path, analyze.ProcessDir)
-		return
-	}
-
-	screen, err := tcell.NewScreen()
-	if err != nil {
-		panic(err)
-	}
-	screen.Init()
-
-	if !flags.noColor {
-		tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227)
-	}
-
-	ui := tui.CreateUI(screen, !flags.noColor)
-
-	if flags.showDisks {
-		if runtime.GOOS == "linux" {
-			ui.ListDevices()
-		} else {
-			fmt.Fprint(writer, "Listing devices is not yet supported for this platform")
-			return
-		}
-	} else {
-		ui.SetIgnoreDirPaths(flags.ignoreDirs)
-		ui.AnalyzePath(path, analyze.ProcessDir, nil)
-	}
-
-	ui.StartUILoop()
-}
diff --git a/main.go b/main.go
index 2d84a30..d5fb580 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,43 @@
 package main
 
-import "github.com/dundee/gdu/cmd"
+import (
+	"fmt"
+	"os"
+
+	"github.com/dundee/gdu/cmd"
+	"github.com/mattn/go-isatty"
+	"github.com/spf13/cobra"
+)
 
 func main() {
-	cmd.Execute()
+	rf := &cmd.RunFlags{}
+
+	var rootCmd = &cobra.Command{
+		Use:   "gdu [directory_to_scan]",
+		Short: "Pretty fast disk usage analyzer written in Go",
+		Long: `Pretty fast disk usage analyzer written in Go.
+
+Gdu is intended primarily for SSD disks where it can fully utilize parallel processing.
+However HDDs work as well, but the performance gain is not so huge.
+`,
+		Args: cobra.MaximumNArgs(1),
+		Run: func(command *cobra.Command, args []string) {
+			istty := isatty.IsTerminal(os.Stdout.Fd())
+			cmd.Run(rf, args, istty, os.Stdout, false)
+		},
+	}
+
+	flags := rootCmd.Flags()
+	flags.StringVarP(&rf.LogFile, "log-file", "l", "/dev/null", "Path to a logfile")
+	flags.StringSliceVarP(&rf.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, "Absolute paths to ignore (separated by comma)")
+	flags.BoolVarP(&rf.ShowDisks, "show-disks", "d", false, "Show all mounted disks")
+	flags.BoolVarP(&rf.ShowVersion, "version", "v", false, "Print version")
+	flags.BoolVarP(&rf.NoColor, "no-color", "c", false, "Do not use colorized output")
+	flags.BoolVarP(&rf.NonInteractive, "non-interactive", "n", false, "Do not run in interactive mode")
+	flags.BoolVarP(&rf.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode")
+
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
 }
diff --git a/stdout/stdout.go b/stdout/stdout.go
index 4b39d8c..5d94b7d 100644
--- a/stdout/stdout.go
+++ b/stdout/stdout.go
@@ -43,8 +43,48 @@ func CreateStdoutUI(output io.Writer, useColors bool, showProgress bool) *UI {
 	return ui
 }
 
+// ListDevices lists mounted devices and shows their disk usage
+func (ui *UI) ListDevices(getter analyze.DevicesInfoGetter) {
+	devices, err := getter("/proc/mounts")
+	if err != nil {
+		panic(err)
+	}
+
+	maxDeviceNameLenght := maxInt(maxLength(
+		devices,
+		func(device *analyze.Device) string { return device.Name },
+	), len("Devices"))
+
+	lineFormat := fmt.Sprintf("%%%ds %%9s %%9s %%9s %%5s %%s\n", maxDeviceNameLenght)
+
+	fmt.Fprintf(
+		ui.output,
+		lineFormat,
+		"Device",
+		"Size",
+		"Used",
+		"Free",
+		"Used%",
+		"Mount point",
+	)
+
+	for _, device := range devices {
+		usedPercent := math.Round(float64(device.Size-device.Free) / float64(device.Size) * 100)
+
+		fmt.Fprintf(
+			ui.output,
+			lineFormat,
+			device.Name,
+			ui.formatSize(device.Size),
+			ui.formatSize(device.Size-device.Free),
+			ui.formatSize(device.Free),
+			ui.red.Sprintf("  %.f%%", usedPercent),
+			device.MountPoint)
+	}
+}
+
 // AnalyzePath analyzes recursively disk usage in given path
-func (ui *UI) AnalyzePath(path string, analyzer analyze.Analyzer) {
+func (ui *UI) AnalyzePath(path string, analyzer analyze.Analyzer, _ *analyze.File) {
 	abspath, _ := filepath.Abs(path)
 	var dir *analyze.File
 
@@ -154,3 +194,22 @@ func (ui *UI) formatSize(size int64) string {
 	}
 	return ui.orange.Sprintf("%d", size) + " B"
 }
+
+func maxLength(list []*analyze.Device, keyGetter func(*analyze.Device) string) int {
+	maxLen := 0
+	var s string
+	for _, item := range list {
+		s = keyGetter(item)
+		if len(s) > maxLen {
+			maxLen = len(s)
+		}
+	}
+	return maxLen
+}
+
+func maxInt(x int, y int) int {
+	if x > y {
+		return x
+	}
+	return y
+}
diff --git a/tui/tui.go b/tui/tui.go
index f580a6a..32423ed 100644
--- a/tui/tui.go
+++ b/tui/tui.go
@@ -35,6 +35,13 @@ const helpText = `
 			  [::b]c    [white:black:-]Sort by items (asc/desc)
 `
 
+// CommonUI is common interface for both terminal UI and text output
+type CommonUI interface {
+	ListDevices(getter analyze.DevicesInfoGetter)
+	AnalyzePath(path string, analyzer analyze.Analyzer, parentDir *analyze.File)
+	SetIgnoreDirPaths(paths []string)
+}
+
 // UI struct
 type UI struct {
 	app             *tview.Application
@@ -117,9 +124,9 @@ func CreateUI(screen tcell.Screen, useColors bool) *UI {
 }
 
 // ListDevices lists mounted devices and shows their disk usage
-func (ui *UI) ListDevices() {
+func (ui *UI) ListDevices(getter analyze.DevicesInfoGetter) {
 	var err error
-	ui.devices, err = analyze.GetDevicesInfo("/proc/mounts")
+	ui.devices, err = getter("/proc/mounts")
 	if err != nil {
 		panic(err)
 	}


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
deleted file mode 100644
index 90c889a..0000000
--- a/cmd/cmd_test.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package cmd
-
-import (
-	"bytes"
-	"testing"
-
-	"github.com/dundee/gdu/analyze"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestVersion(t *testing.T) {
-
-	buff := bytes.NewBuffer(make([]byte, 10))
-
-	scan(&scanFlags{showVersion: true}, []string{}, false, buff)
-
-	assert.Contains(t, buff.String(), "Version:\t development")
-}
-
-func TestAnalyzePath(t *testing.T) {
-	fin := analyze.CreateTestDir()
-	defer fin()
-
-	buff := bytes.NewBuffer(make([]byte, 10))
-
-	scan(&scanFlags{logFile: "/dev/null"}, []string{"test_dir"}, false, buff)
-
-	assert.Contains(t, buff.String(), "nested")
-}
diff --git a/cmd/run_test.go b/cmd/run_test.go
new file mode 100644
index 0000000..deac461
--- /dev/null
+++ b/cmd/run_test.go
@@ -0,0 +1,58 @@
+package cmd
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/dundee/gdu/analyze"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestVersion(t *testing.T) {
+
+	buff := bytes.NewBuffer(make([]byte, 10))
+
+	Run(&RunFlags{ShowVersion: true}, []string{}, false, buff, true)
+
+	assert.Contains(t, buff.String(), "Version:\t development")
+}
+
+func TestAnalyzePath(t *testing.T) {
+	fin := analyze.CreateTestDir()
+	defer fin()
+
+	buff := bytes.NewBuffer(make([]byte, 10))
+
+	Run(&RunFlags{LogFile: "/dev/null"}, []string{"test_dir"}, false, buff, true)
+
+	assert.Contains(t, buff.String(), "nested")
+}
+
+func TestAnalyzePathWithGui(t *testing.T) {
+	fin := analyze.CreateTestDir()
+	defer fin()
+
+	buff := bytes.NewBuffer(make([]byte, 10))
+
+	Run(&RunFlags{LogFile: "/dev/null"}, []string{"test_dir"}, true, buff, true)
+}
+
+func TestListDevices(t *testing.T) {
+	fin := analyze.CreateTestDir()
+	defer fin()
+
+	buff := bytes.NewBuffer(make([]byte, 10))
+
+	Run(&RunFlags{LogFile: "/dev/null", ShowDisks: true}, nil, false, buff, true)
+
+	assert.Contains(t, buff.String(), "dev")
+}
+
+func TestListDevicesWithGui(t *testing.T) {
+	fin := analyze.CreateTestDir()
+	defer fin()
+
+	buff := bytes.NewBuffer(make([]byte, 10))
+
+	Run(&RunFlags{LogFile: "/dev/null", ShowDisks: true}, nil, true, buff, true)
+}
diff --git a/stdout/stdout_test.go b/stdout/stdout_test.go
index 3979342..2bd8d80 100644
--- a/stdout/stdout_test.go
+++ b/stdout/stdout_test.go
@@ -17,7 +17,7 @@ func TestAnalyzePath(t *testing.T) {
 
 	ui := CreateStdoutUI(output, false, false)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
-	ui.AnalyzePath("test_dir", analyze.ProcessDir)
+	ui.AnalyzePath("test_dir", analyze.ProcessDir, nil)
 
 	assert.Contains(t, output.String(), "nested")
 }
@@ -31,7 +31,7 @@ func TestAnalyzePathWithColors(t *testing.T) {
 
 	ui := CreateStdoutUI(output, true, false)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
-	ui.AnalyzePath("test_dir/nested", analyze.ProcessDir)
+	ui.AnalyzePath("test_dir/nested", analyze.ProcessDir, nil)
 
 	assert.Contains(t, output.String(), "subnested")
 }
@@ -44,11 +44,26 @@ func TestAnalyzePathWithProgress(t *testing.T) {
 
 	ui := CreateStdoutUI(output, false, true)
 	ui.SetIgnoreDirPaths([]string{"/xxx"})
-	ui.AnalyzePath("test_dir", analyze.ProcessDir)
+	ui.AnalyzePath("test_dir", analyze.ProcessDir, nil)
 
 	assert.Contains(t, output.String(), "nested")
 }
 
+func TestShowDevices(t *testing.T) {
+	output := bytes.NewBuffer(make([]byte, 10))
+
+	ui := CreateStdoutUI(output, false, true)
+	ui.ListDevices(func(_ string) ([]*analyze.Device, error) {
+		item := &analyze.Device{
+			Name: "xxx",
+		}
+		return []*analyze.Device{item}, nil
+	})
+
+	assert.Contains(t, output.String(), "Device")
+	assert.Contains(t, output.String(), "xxx")
+}
+
 func printBuffer(buff *bytes.Buffer) {
 	for i, x := range buff.String() {
 		println(i, string(x))
diff --git a/tui/tui_test.go b/tui/tui_test.go
index 89d4345..e416ff3 100644
--- a/tui/tui_test.go
+++ b/tui/tui_test.go
@@ -221,7 +221,7 @@ func TestShowDevices(t *testing.T) {
 	simScreen.SetSize(50, 50)
 
 	ui := CreateUI(simScreen, false)
-	ui.ListDevices()
+	ui.ListDevices(getDevicesInfoMock)
 	ui.table.Draw(simScreen)
 	simScreen.Show()
 
@@ -244,7 +244,7 @@ func TestShowDevicesBW(t *testing.T) {
 	simScreen.SetSize(50, 50)
 
 	ui := CreateUI(simScreen, true)
-	ui.ListDevices()
+	ui.ListDevices(getDevicesInfoMock)
 	ui.table.Draw(simScreen)
 	simScreen.Show()
 
@@ -268,7 +268,7 @@ func TestSelectDevice(t *testing.T) {
 	ui := CreateUI(simScreen, true)
 	ui.SetIgnoreDirPaths([]string{"/"})
 	ui.analyzer = analyzeMock
-	ui.ListDevices()
+	ui.ListDevices(getDevicesInfoMock)
 
 	go func() {
 		time.Sleep(10 * time.Millisecond)
@@ -363,3 +363,10 @@ func analyzeMock(path string, progress *analyze.CurrentProgress, ignore analyze.
 		BasePath: ".",
 	}
 }
+
+func getDevicesInfoMock(_ string) ([]*analyze.Device, error) {
+	item := &analyze.Device{
+		Name: "xxx",
+	}
+	return []*analyze.Device{item}, nil
+}